@abloatai/ablo 0.7.0 → 0.9.0
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.
- package/CHANGELOG.md +72 -1
- package/README.md +80 -66
- package/dist/BaseSyncedStore.d.ts +73 -0
- package/dist/BaseSyncedStore.js +179 -5
- package/dist/Model.d.ts +42 -0
- package/dist/Model.js +103 -44
- package/dist/SyncEngineContext.d.ts +2 -1
- package/dist/SyncEngineContext.js +5 -3
- package/dist/agent/session.js +6 -5
- package/dist/ai-sdk/coordination-context.js +4 -0
- package/dist/ai-sdk/index.d.ts +56 -47
- package/dist/ai-sdk/index.js +56 -47
- package/dist/ai-sdk/intent-broadcast.d.ts +5 -0
- package/dist/ai-sdk/intent-broadcast.js +11 -4
- package/dist/ai-sdk/wrap.d.ts +14 -11
- package/dist/ai-sdk/wrap.js +11 -13
- package/dist/auth/credentialSource.d.ts +34 -0
- package/dist/auth/credentialSource.js +63 -0
- package/dist/auth/index.d.ts +2 -22
- package/dist/auth/index.js +26 -36
- package/dist/auth/schemas.d.ts +35 -0
- package/dist/auth/schemas.js +53 -0
- package/dist/client/Ablo.d.ts +259 -33
- package/dist/client/Ablo.js +276 -73
- package/dist/client/ApiClient.d.ts +52 -4
- package/dist/client/ApiClient.js +236 -66
- package/dist/client/auth.d.ts +21 -2
- package/dist/client/auth.js +77 -5
- package/dist/client/createInternalComponents.d.ts +2 -0
- package/dist/client/createInternalComponents.js +8 -1
- package/dist/client/createModelProxy.d.ts +187 -79
- package/dist/client/createModelProxy.js +203 -68
- package/dist/client/httpClient.d.ts +71 -0
- package/dist/client/httpClient.js +69 -0
- package/dist/client/identity.d.ts +2 -6
- package/dist/client/identity.js +63 -11
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/registerDataSource.d.ts +19 -0
- package/dist/client/registerDataSource.js +59 -0
- package/dist/client/validateAbloOptions.d.ts +2 -1
- package/dist/client/validateAbloOptions.js +8 -7
- package/dist/core/DatabaseManager.js +30 -2
- package/dist/core/openIDBWithTimeout.d.ts +36 -0
- package/dist/core/openIDBWithTimeout.js +88 -1
- package/dist/errorCodes.d.ts +92 -1
- package/dist/errorCodes.js +139 -7
- package/dist/errors.d.ts +54 -3
- package/dist/errors.js +192 -44
- package/dist/index.d.ts +23 -10
- package/dist/index.js +21 -8
- package/dist/keys/index.d.ts +76 -0
- package/dist/keys/index.js +171 -0
- package/dist/mutators/UndoManager.d.ts +86 -50
- package/dist/mutators/UndoManager.js +129 -22
- package/dist/mutators/inverseOp.d.ts +129 -0
- package/dist/mutators/inverseOp.js +74 -0
- package/dist/mutators/readerActions.d.ts +1 -1
- package/dist/mutators/undoApply.d.ts +42 -0
- package/dist/mutators/undoApply.js +143 -0
- package/dist/query/client.d.ts +10 -9
- package/dist/query/client.js +22 -14
- package/dist/react/AbloProvider.d.ts +23 -101
- package/dist/react/AbloProvider.js +61 -103
- package/dist/react/ClientSideSuspense.d.ts +1 -1
- package/dist/react/DefaultFallback.d.ts +1 -1
- package/dist/react/SyncGroupProvider.d.ts +1 -1
- package/dist/react/index.d.ts +3 -2
- package/dist/react/index.js +3 -2
- package/dist/react/useAblo.d.ts +4 -4
- package/dist/react/useAblo.js +10 -5
- package/dist/react/useCurrentUserId.d.ts +1 -1
- package/dist/react/useCurrentUserId.js +1 -1
- package/dist/react/useMutators.js +19 -12
- package/dist/react/useReactive.js +16 -3
- package/dist/schema/ddl.d.ts +26 -3
- package/dist/schema/ddl.js +152 -4
- package/dist/schema/index.d.ts +4 -0
- package/dist/schema/index.js +12 -0
- package/dist/schema/model.d.ts +11 -0
- package/dist/schema/model.js +2 -0
- package/dist/schema/openapi.d.ts +28 -0
- package/dist/schema/openapi.js +118 -0
- package/dist/schema/plane.d.ts +23 -0
- package/dist/schema/plane.js +19 -0
- package/dist/schema/relation.d.ts +20 -0
- package/dist/schema/serialize.d.ts +7 -3
- package/dist/schema/serialize.js +6 -2
- package/dist/schema/sync-delta-row.d.ts +157 -0
- package/dist/schema/sync-delta-row.js +102 -0
- package/dist/schema/sync-delta-wire.d.ts +180 -0
- package/dist/schema/sync-delta-wire.js +102 -0
- package/dist/server/adapter.d.ts +156 -0
- package/dist/server/adapter.js +19 -0
- package/dist/server/commit.d.ts +82 -0
- package/dist/server/commit.js +1 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +1 -0
- package/dist/server/next.d.ts +51 -0
- package/dist/server/next.js +47 -0
- package/dist/server/read-config.d.ts +60 -0
- package/dist/server/read-config.js +8 -0
- package/dist/server/storage-mode.d.ts +17 -0
- package/dist/server/storage-mode.js +12 -0
- package/dist/source/adapter.d.ts +59 -0
- package/dist/source/adapter.js +19 -0
- package/dist/source/adapters/drizzle.d.ts +34 -0
- package/dist/source/adapters/drizzle.js +147 -0
- package/dist/source/adapters/memory.d.ts +12 -0
- package/dist/source/adapters/memory.js +114 -0
- package/dist/source/adapters/prisma.d.ts +57 -0
- package/dist/source/adapters/prisma.js +199 -0
- package/dist/source/conformance.d.ts +32 -0
- package/dist/source/conformance.js +134 -0
- package/dist/source/contract.d.ts +143 -0
- package/dist/source/contract.js +98 -0
- package/dist/source/index.d.ts +61 -10
- package/dist/source/index.js +98 -0
- package/dist/source/next.d.ts +33 -0
- package/dist/source/next.js +26 -0
- package/dist/sync/BootstrapHelper.d.ts +10 -0
- package/dist/sync/BootstrapHelper.js +56 -42
- package/dist/sync/ConnectionManager.d.ts +57 -1
- package/dist/sync/ConnectionManager.js +186 -11
- package/dist/sync/HydrationCoordinator.d.ts +93 -17
- package/dist/sync/HydrationCoordinator.js +241 -41
- package/dist/sync/NetworkProbe.d.ts +60 -18
- package/dist/sync/NetworkProbe.js +121 -23
- package/dist/sync/SyncWebSocket.d.ts +45 -70
- package/dist/sync/SyncWebSocket.js +113 -89
- package/dist/sync/createIntentStream.js +10 -1
- package/dist/sync/participants.js +5 -2
- package/dist/transactions/TransactionQueue.js +13 -1
- package/dist/types/streams.d.ts +9 -0
- package/dist/utils/mobx-setup.js +1 -0
- package/dist/webhooks/events.d.ts +38 -0
- package/dist/webhooks/events.js +40 -0
- package/dist/webhooks/index.d.ts +10 -0
- package/dist/webhooks/index.js +10 -0
- package/dist/wire/errorEnvelope.d.ts +34 -0
- package/dist/wire/errorEnvelope.js +86 -0
- package/dist/wire/frames.d.ts +119 -0
- package/dist/wire/frames.js +1 -0
- package/dist/wire/index.d.ts +24 -0
- package/dist/wire/index.js +21 -0
- package/dist/wire/listEnvelope.d.ts +45 -0
- package/dist/wire/listEnvelope.js +17 -0
- package/docs/api-keys.md +5 -5
- package/docs/api.md +125 -65
- package/docs/audit.md +16 -9
- package/docs/cli.md +57 -47
- package/docs/client-behavior.md +54 -40
- package/docs/coordination.md +66 -80
- package/docs/data-sources.md +56 -34
- package/docs/examples/agent-human.md +74 -28
- package/docs/examples/ai-sdk-tool.md +29 -22
- package/docs/examples/existing-python-backend.md +41 -26
- package/docs/examples/nextjs.md +32 -17
- package/docs/examples/scoped-agent.md +43 -28
- package/docs/examples/server-agent.md +40 -15
- package/docs/guarantees.md +38 -27
- package/docs/identity.md +65 -59
- package/docs/index.md +30 -19
- package/docs/integration-guide.md +78 -78
- package/docs/interaction-model.md +43 -35
- package/docs/mcp/claude-code.md +11 -19
- package/docs/mcp/cursor.md +7 -25
- package/docs/mcp/windsurf.md +7 -20
- package/docs/mcp.md +103 -26
- package/docs/quickstart.md +63 -61
- package/docs/react.md +24 -16
- package/docs/roadmap.md +13 -13
- package/docs/schema-contract.md +111 -0
- package/docs/the-loop.md +21 -0
- package/examples/README.md +8 -4
- package/examples/data-source/README.md +10 -7
- package/examples/data-source/customer-server.ts +27 -25
- package/examples/data-source/run.ts +4 -3
- package/examples/quickstart.ts +1 -1
- package/llms.txt +55 -21
- package/package.json +48 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ERROR egress — turn ANY thrown value into Stripe's error-object envelope plus
|
|
3
|
+
* an HTTP status, so every error response across the Ablo surface carries the
|
|
4
|
+
* identical `{ type, code, param, message, doc_url, request_id }` shape
|
|
5
|
+
* regardless of which route or service produced it.
|
|
6
|
+
*
|
|
7
|
+
* This is the wire-PRODUCE counterpart to `errors.ts`'s wire-PARSE
|
|
8
|
+
* (`translateHttpError`/`errorFromWire`). It lives in `wire/` — not in the main
|
|
9
|
+
* SDK entry — so a server-side consumer (a Next.js route) can import it without
|
|
10
|
+
* dragging in the client runtime (mobx/react/IndexedDB).
|
|
11
|
+
*
|
|
12
|
+
* The classifier is the UNIVERSAL baseline: a typed {@link AbloError} passes
|
|
13
|
+
* through (subclass + code + httpStatus preserved), everything else degrades to
|
|
14
|
+
* a 500 `internal_error`. Service-specific normalization that needs a DB driver
|
|
15
|
+
* (apps/sync-server classifies raw Postgres SQLSTATE + MutatorError) is layered
|
|
16
|
+
* on top in that service — it is intentionally NOT pulled into the shared,
|
|
17
|
+
* dependency-free contract.
|
|
18
|
+
*/
|
|
19
|
+
import { AbloError, docUrlForCode } from '../errors.js';
|
|
20
|
+
import { errorCodeSpec } from '../errorCodes.js';
|
|
21
|
+
/** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
|
|
22
|
+
* match status semantics (a validation error is a 400, a permission error a
|
|
23
|
+
* 403), so a throw site only picks the right class + code and the status
|
|
24
|
+
* follows — an explicit `httpStatus` is passed only when it diverges (e.g. a
|
|
25
|
+
* 404 on the base class, a 503 on AbloServerError). Mirrors the same table in
|
|
26
|
+
* apps/sync-server's self-contained `errors.ts`. */
|
|
27
|
+
export function statusForType(type) {
|
|
28
|
+
switch (type) {
|
|
29
|
+
case 'AbloAuthenticationError':
|
|
30
|
+
return 401;
|
|
31
|
+
case 'AbloPermissionError':
|
|
32
|
+
return 403;
|
|
33
|
+
case 'AbloValidationError':
|
|
34
|
+
return 400;
|
|
35
|
+
case 'AbloRateLimitError':
|
|
36
|
+
return 429;
|
|
37
|
+
case 'AbloIdempotencyError':
|
|
38
|
+
case 'AbloStaleContextError':
|
|
39
|
+
case 'AbloClaimedError':
|
|
40
|
+
return 409;
|
|
41
|
+
case 'AbloConnectionError':
|
|
42
|
+
return 503;
|
|
43
|
+
case 'AbloServerError':
|
|
44
|
+
return 500;
|
|
45
|
+
default:
|
|
46
|
+
return 500;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Convert ANY thrown value into the canonical {@link ErrorEnvelope} plus an
|
|
51
|
+
* HTTP status. A typed {@link AbloError} is serialized via its own `toJSON`
|
|
52
|
+
* (so `code`/`param`/`doc_url`/structured `details` survive) and gets its
|
|
53
|
+
* status from an explicit `httpStatus` or, failing that, {@link statusForType}.
|
|
54
|
+
* Anything else degrades to a 500 `internal_error` envelope — never a bare
|
|
55
|
+
* framework "Internal Server Error" text body, and never a raw error string
|
|
56
|
+
* leaked onto the wire as an unregistered code.
|
|
57
|
+
*
|
|
58
|
+
* `requestId` is stamped into the body when the error didn't already carry one,
|
|
59
|
+
* so the response and the `x-request-id` header agree for support correlation.
|
|
60
|
+
*/
|
|
61
|
+
export function errorEnvelope(err, requestId) {
|
|
62
|
+
if (err instanceof AbloError) {
|
|
63
|
+
// Status precedence: an explicit httpStatus wins; else the code's canonical
|
|
64
|
+
// status from the registry (so `new AbloError('…', { code: 'entity_not_found' })`
|
|
65
|
+
// is a 404 without the throw site repeating it); else the subclass default.
|
|
66
|
+
const status = err.httpStatus ??
|
|
67
|
+
(err.code ? errorCodeSpec(err.code)?.httpStatus : undefined) ??
|
|
68
|
+
statusForType(err.type);
|
|
69
|
+
const body = err.toJSON();
|
|
70
|
+
return {
|
|
71
|
+
body: requestId && body.request_id === undefined ? { ...body, request_id: requestId } : body,
|
|
72
|
+
status,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return {
|
|
77
|
+
body: {
|
|
78
|
+
type: 'AbloServerError',
|
|
79
|
+
code: 'internal_error',
|
|
80
|
+
message,
|
|
81
|
+
doc_url: docUrlForCode('internal_error'),
|
|
82
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
83
|
+
},
|
|
84
|
+
status: 500,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/wire` — canonical COMMIT-PATH frame contract.
|
|
3
|
+
*
|
|
4
|
+
* These are the WebSocket (and HTTP-fallback) message shapes for the
|
|
5
|
+
* write path: the client's `commit` / `mutation` frames and the server's
|
|
6
|
+
* `mutation_result` ack. They live here — not in the server app and not
|
|
7
|
+
* inlined in the SDK's `SyncWebSocket` — so the client, the server, and
|
|
8
|
+
* any future `@abloatai/ablo/server` host all import ONE definition
|
|
9
|
+
* and cannot drift.
|
|
10
|
+
*
|
|
11
|
+
* Scope note: the delta/sync frames (`sync_response`, `delta`) are NOT
|
|
12
|
+
* here yet — they reference `SyncDelta`, which currently has two
|
|
13
|
+
* definitions (server `db/deltas` vs package `core`) pending unification.
|
|
14
|
+
* They stay server-local until that lands. Everything in this file
|
|
15
|
+
* depends only on package-canonical types (`OnStaleMode`, `ErrorCode`,
|
|
16
|
+
* `RequiredCapability`), so it is safe to share today.
|
|
17
|
+
*
|
|
18
|
+
* Changing any shape here is a wire-contract change — it requires
|
|
19
|
+
* coordinated client + server updates.
|
|
20
|
+
*/
|
|
21
|
+
import type { OnStaleMode } from '../coordination/index.js';
|
|
22
|
+
import type { ErrorCode, RequiredCapability } from '../errors.js';
|
|
23
|
+
/**
|
|
24
|
+
* A single operation within a {@link CommitMessage} batch. The atomic unit
|
|
25
|
+
* the server's commit executor applies (and, once the mutator seam lands,
|
|
26
|
+
* the raw-op fallback path when no named mutator is registered).
|
|
27
|
+
*/
|
|
28
|
+
export interface CommitOperation {
|
|
29
|
+
type: 'CREATE' | 'UPDATE' | 'DELETE' | 'ARCHIVE' | 'UNARCHIVE';
|
|
30
|
+
model: string;
|
|
31
|
+
id?: string | null;
|
|
32
|
+
input?: Record<string, unknown> | null;
|
|
33
|
+
/**
|
|
34
|
+
* Per-op client transaction id. Stamped onto `sync_deltas.transaction_id`
|
|
35
|
+
* so the originating client can recognize the broadcast as an echo of its
|
|
36
|
+
* own optimistic mutation. Distinct from the batch-level `clientTxId`
|
|
37
|
+
* (which keys `mutation_log` for retry idempotency).
|
|
38
|
+
*/
|
|
39
|
+
transactionId?: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Watermark from `context.capture`. The server checks whether the target
|
|
42
|
+
* has received deltas since this id; if so the operation's `onStale` mode
|
|
43
|
+
* applies.
|
|
44
|
+
*/
|
|
45
|
+
readAt?: number | null;
|
|
46
|
+
/**
|
|
47
|
+
* Mode on stale detection. `'reject'` (default) throws
|
|
48
|
+
* AbloStaleContextError; `'force'` applies unconditionally. `'flag'` /
|
|
49
|
+
* `'merge'` are reserved, not yet implemented.
|
|
50
|
+
*/
|
|
51
|
+
onStale?: OnStaleMode | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Client → Server single named-mutation frame. The named-mutator write
|
|
55
|
+
* primitive (intent + args), as opposed to the raw-op {@link CommitMessage}
|
|
56
|
+
* batch. Server-side mutator dispatch resolves `mutatorName` against the
|
|
57
|
+
* host-provided registry.
|
|
58
|
+
*/
|
|
59
|
+
export interface MutationMessage {
|
|
60
|
+
type: 'mutation';
|
|
61
|
+
payload: {
|
|
62
|
+
mutatorName: string;
|
|
63
|
+
input: unknown;
|
|
64
|
+
clientTxId: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Client → Server "commit this batch of operations" frame. Formerly named
|
|
69
|
+
* `batch_ack` / `BatchAckMessage` — renamed pre-stable to the customer-facing
|
|
70
|
+
* verb (`commit`) consistently across the wire and the SDK method
|
|
71
|
+
* (`MutationExecutor.commit`).
|
|
72
|
+
*/
|
|
73
|
+
export interface CommitMessage {
|
|
74
|
+
type: 'commit';
|
|
75
|
+
payload: {
|
|
76
|
+
operations: CommitOperation[];
|
|
77
|
+
clientTxId: string;
|
|
78
|
+
/**
|
|
79
|
+
* Optional turn handle. When the SDK opens a turn via
|
|
80
|
+
* `SyncAgent.beginTurn(...)`, subsequent commits within the handle's
|
|
81
|
+
* scope auto-attach the `turnId` here. The Hub validates the turn
|
|
82
|
+
* belongs to the same agent and is open, then threads it onto every
|
|
83
|
+
* delta's `caused_by_task_id` column. Absent for human-direct commits
|
|
84
|
+
* and for SDKs that predate the turn protocol — those produce deltas
|
|
85
|
+
* with `caused_by_task_id = NULL`, which the audit pane treats as "no
|
|
86
|
+
* prompt-side context recorded."
|
|
87
|
+
*/
|
|
88
|
+
causedByTaskId?: string | null;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wire ack for a `commit` frame. Payload mirrors the canonical
|
|
93
|
+
* `CommitReceipt` shape so WebSocket, HTTP `/v1/commits`, and persisted
|
|
94
|
+
* `AgentJob.result.receipt` all carry identical fields.
|
|
95
|
+
*
|
|
96
|
+
* `object`, `status`, and `ops` are typed optional because pre-unification
|
|
97
|
+
* WS clients didn't ship them; servers always populate them on the way out.
|
|
98
|
+
* New clients can rely on them.
|
|
99
|
+
*/
|
|
100
|
+
export interface MutationResultMessage {
|
|
101
|
+
type: 'mutation_result';
|
|
102
|
+
payload: {
|
|
103
|
+
object?: 'commit_receipt';
|
|
104
|
+
clientTxId: string;
|
|
105
|
+
serverTxId: string;
|
|
106
|
+
success: boolean;
|
|
107
|
+
status?: 'confirmed' | 'rejected';
|
|
108
|
+
lastSyncId?: number;
|
|
109
|
+
ops?: number;
|
|
110
|
+
error?: {
|
|
111
|
+
code: ErrorCode;
|
|
112
|
+
message: string;
|
|
113
|
+
field?: string;
|
|
114
|
+
/** Structured rejection body (x402-style) emitted when the cap
|
|
115
|
+
* verifier denies the commit. */
|
|
116
|
+
requiredCapability?: RequiredCapability;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/wire` — the canonical HTTP/frame WIRE CONTRACT, with no
|
|
3
|
+
* client-runtime (mobx / react / IndexedDB) dependency, so a server-side
|
|
4
|
+
* consumer — a Next.js route handler, an edge function — can import the
|
|
5
|
+
* envelope producers without pulling in the whole sync client.
|
|
6
|
+
*
|
|
7
|
+
* Two halves, both Stripe-shaped and used across every Ablo surface:
|
|
8
|
+
* - ERROR egress — {@link errorEnvelope} / {@link ErrorEnvelope} /
|
|
9
|
+
* {@link statusForType} turn any thrown value into
|
|
10
|
+
* `{ type, code, param, message, doc_url, request_id }`.
|
|
11
|
+
* - LIST egress — {@link listEnvelope} / {@link ListEnvelope} stamp the
|
|
12
|
+
* uniform `{ object: 'list', data, has_more, next_cursor }` collection.
|
|
13
|
+
*
|
|
14
|
+
* The {@link AbloError} hierarchy + {@link docUrlForCode} + the wire-PARSE
|
|
15
|
+
* helpers are re-exported so a route can THROW the right typed error and
|
|
16
|
+
* SERIALIZE it through a single import.
|
|
17
|
+
*/
|
|
18
|
+
export { errorEnvelope, statusForType } from './errorEnvelope.js';
|
|
19
|
+
export type { ErrorEnvelope } from './errorEnvelope.js';
|
|
20
|
+
export { listEnvelope } from './listEnvelope.js';
|
|
21
|
+
export type { ListEnvelope } from './listEnvelope.js';
|
|
22
|
+
export type { CommitOperation, MutationMessage, CommitMessage, MutationResultMessage, } from './frames.js';
|
|
23
|
+
export { AbloError, AbloAuthenticationError, AbloPermissionError, AbloValidationError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, SyncSessionError, docUrlForCode, translateHttpError, errorFromWire, toAbloError, ERROR_CONTRACT_VERSION, } from '../errors.js';
|
|
24
|
+
export type { ErrorCode, WireErrorCode } from '../errors.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@abloatai/ablo/wire` — the canonical HTTP/frame WIRE CONTRACT, with no
|
|
3
|
+
* client-runtime (mobx / react / IndexedDB) dependency, so a server-side
|
|
4
|
+
* consumer — a Next.js route handler, an edge function — can import the
|
|
5
|
+
* envelope producers without pulling in the whole sync client.
|
|
6
|
+
*
|
|
7
|
+
* Two halves, both Stripe-shaped and used across every Ablo surface:
|
|
8
|
+
* - ERROR egress — {@link errorEnvelope} / {@link ErrorEnvelope} /
|
|
9
|
+
* {@link statusForType} turn any thrown value into
|
|
10
|
+
* `{ type, code, param, message, doc_url, request_id }`.
|
|
11
|
+
* - LIST egress — {@link listEnvelope} / {@link ListEnvelope} stamp the
|
|
12
|
+
* uniform `{ object: 'list', data, has_more, next_cursor }` collection.
|
|
13
|
+
*
|
|
14
|
+
* The {@link AbloError} hierarchy + {@link docUrlForCode} + the wire-PARSE
|
|
15
|
+
* helpers are re-exported so a route can THROW the right typed error and
|
|
16
|
+
* SERIALIZE it through a single import.
|
|
17
|
+
*/
|
|
18
|
+
export { errorEnvelope, statusForType } from './errorEnvelope.js';
|
|
19
|
+
export { listEnvelope } from './listEnvelope.js';
|
|
20
|
+
// The error surface a wire consumer needs to throw, classify, and serialize.
|
|
21
|
+
export { AbloError, AbloAuthenticationError, AbloPermissionError, AbloValidationError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, SyncSessionError, docUrlForCode, translateHttpError, errorFromWire, toAbloError, ERROR_CONTRACT_VERSION, } from '../errors.js';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical Ablo LIST envelope — the one shape every endpoint that returns
|
|
3
|
+
* a collection uses, so a consumer can detect + paginate any list uniformly
|
|
4
|
+
* instead of learning a per-endpoint payload key (`{ keys }`, `{ origins }`,
|
|
5
|
+
* `{ events }`, `{ buckets }`…).
|
|
6
|
+
*
|
|
7
|
+
* `{ object: 'list', data: [...], has_more, next_cursor }` is the shape the
|
|
8
|
+
* hosted `GET /v1/models/:model` endpoint already emits (apps/sync-server
|
|
9
|
+
* `routes/query.ts`) and that `@ablo/mcp` already consumes — promoted here so
|
|
10
|
+
* sync-web's dashboard lists, the SDK, and any future surface produce the
|
|
11
|
+
* identical envelope from one definition.
|
|
12
|
+
*
|
|
13
|
+
* The field NAMES are Stripe's (`object`/`has_more`/`next_cursor`), not
|
|
14
|
+
* PlanetScale's (`type`/`cursor_start`/`has_next`): the rest of the Ablo API is
|
|
15
|
+
* Stripe-modeled, so this keeps one vocabulary across the surface. The
|
|
16
|
+
* PlanetScale discipline we deliberately borrow is *"every list is the same
|
|
17
|
+
* envelope"* — not the concrete key names.
|
|
18
|
+
*/
|
|
19
|
+
export interface ListEnvelope<T> {
|
|
20
|
+
/** Discriminator — always `'list'`. Lets a generic client recognise a
|
|
21
|
+
* paginated collection without per-endpoint special-casing. */
|
|
22
|
+
readonly object: 'list';
|
|
23
|
+
/** The page of results. Always present (an empty array when there are none),
|
|
24
|
+
* never omitted, so `body.data` is a stable access path. */
|
|
25
|
+
readonly data: readonly T[];
|
|
26
|
+
/** Whether more results exist past this page. Drive "load more" off this,
|
|
27
|
+
* not off `data.length === limit` (ambiguous on an exact-multiple page). */
|
|
28
|
+
readonly has_more: boolean;
|
|
29
|
+
/** Opaque cursor to pass back as `?starting_after=` for the next page, or
|
|
30
|
+
* `null` when {@link has_more} is `false`. */
|
|
31
|
+
readonly next_cursor: string | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Stamp the uniform {@link ListEnvelope} onto an already-resolved page of rows.
|
|
35
|
+
*
|
|
36
|
+
* Pagination stays the caller's responsibility (fetch `limit + 1`, decide
|
|
37
|
+
* `hasMore`, derive the cursor from the last row's order key) — this only
|
|
38
|
+
* applies the envelope so no endpoint hand-rolls the shape. The defaults model
|
|
39
|
+
* the common "small, unpaginated collection" case (`has_more: false`,
|
|
40
|
+
* `next_cursor: null`); a paginated endpoint passes both explicitly.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listEnvelope<T>(data: readonly T[], opts?: {
|
|
43
|
+
hasMore?: boolean;
|
|
44
|
+
nextCursor?: string | null;
|
|
45
|
+
}): ListEnvelope<T>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stamp the uniform {@link ListEnvelope} onto an already-resolved page of rows.
|
|
3
|
+
*
|
|
4
|
+
* Pagination stays the caller's responsibility (fetch `limit + 1`, decide
|
|
5
|
+
* `hasMore`, derive the cursor from the last row's order key) — this only
|
|
6
|
+
* applies the envelope so no endpoint hand-rolls the shape. The defaults model
|
|
7
|
+
* the common "small, unpaginated collection" case (`has_more: false`,
|
|
8
|
+
* `next_cursor: null`); a paginated endpoint passes both explicitly.
|
|
9
|
+
*/
|
|
10
|
+
export function listEnvelope(data, opts = {}) {
|
|
11
|
+
return {
|
|
12
|
+
object: 'list',
|
|
13
|
+
data,
|
|
14
|
+
has_more: opts.hasMore ?? false,
|
|
15
|
+
next_cursor: opts.nextCursor ?? null,
|
|
16
|
+
};
|
|
17
|
+
}
|
package/docs/api-keys.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# API Keys
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Authenticate a server-side client — a route handler, worker, or CLI — by passing an API key when you create the client.
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
6
|
import Ablo from '@abloatai/ablo';
|
|
@@ -10,11 +10,11 @@ const ablo = Ablo({ apiKey: process.env.ABLO_API_KEY });
|
|
|
10
10
|
|
|
11
11
|
The key identifies the Ablo account. Application code does not pass an organization id; Ablo derives scope from the credential.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
"Trusted" means the runtime can hold a secret: a backend or other server-side environment a browser can't read. Browser and app clients use the same `@abloatai/ablo` import but authenticate differently — they never carry a secret key.
|
|
14
14
|
|
|
15
15
|
## Server-Side API Keys
|
|
16
16
|
|
|
17
|
-
Use API keys from trusted runtimes:
|
|
17
|
+
Use API keys from trusted (server-side) runtimes:
|
|
18
18
|
|
|
19
19
|
- backend route handlers
|
|
20
20
|
- workers and agents
|
|
@@ -48,8 +48,8 @@ restricted to exactly those grants:
|
|
|
48
48
|
high-risk, org-wide grant: because schema is shared, a push affects the live
|
|
49
49
|
table shape. A full-authority key has it implicitly; a *restricted* key (such
|
|
50
50
|
as a sandbox key) needs it granted explicitly.
|
|
51
|
-
- `sandbox:<id>` —
|
|
52
|
-
comes from
|
|
51
|
+
- `sandbox:<id>` — identifies which sandbox the key belongs to. (The key's data
|
|
52
|
+
isolation comes from that sandbox binding, not from this scope string.)
|
|
53
53
|
|
|
54
54
|
A key minted from the default **Test mode** sandbox carries `schema:push`, so
|
|
55
55
|
`ablo dev` works out of the box. Keys from other sandboxes are **data-only** by
|
package/docs/api.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
# API
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
This is the per-method reference for reading and writing rows that stay in
|
|
4
|
+
sync across sessions. You declare your models once, then call the same
|
|
5
|
+
`ablo.<model>` methods from React, a server action, or an agent — and every
|
|
6
|
+
confirmed write streams to everyone watching. When two writers touch the same
|
|
7
|
+
row, you can optionally `claim` it so they serialize instead of clobbering
|
|
8
|
+
each other.
|
|
9
|
+
|
|
10
|
+
Two things to know before the method list. **Reads come in two flavors:**
|
|
11
|
+
`retrieve(id)` / `list({ where })` are async and hit the server (use them when
|
|
12
|
+
the row may not be local yet); `get(id)` / `getAll({ where })` / `getCount({ where })`
|
|
13
|
+
are synchronous reads off the local graph (use them in render, after data has
|
|
14
|
+
synced). **Claims don't lock.** If another writer holds the row, `claim` waits
|
|
15
|
+
for them, re-reads the fresh row, then hands it to you — so two writers
|
|
16
|
+
serialize instead of clobbering.
|
|
7
17
|
|
|
18
|
+
Start with the schema client:
|
|
8
19
|
|
|
9
20
|
```ts
|
|
10
21
|
import Ablo from '@abloatai/ablo';
|
|
@@ -20,39 +31,46 @@ const schema = defineSchema({
|
|
|
20
31
|
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
21
32
|
|
|
22
33
|
await ablo.ready();
|
|
23
|
-
const
|
|
34
|
+
const report = await ablo.weatherReports.retrieve({ id: 'report_stockholm' });
|
|
24
35
|
if (!report) throw new Error('Row not found');
|
|
25
36
|
|
|
26
|
-
await ablo.weatherReports.update('report_stockholm', { status: 'ready' },
|
|
37
|
+
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' }, wait: 'confirmed' });
|
|
27
38
|
```
|
|
28
39
|
|
|
40
|
+
For end-to-end app setup across React, existing backends, Data Source, and
|
|
41
|
+
agents, read the [Integration Guide](./integration-guide.md).
|
|
42
|
+
|
|
29
43
|
## Model Methods
|
|
30
44
|
|
|
31
45
|
Each schema model becomes a typed model on the client:
|
|
32
46
|
|
|
33
|
-
- `ablo.weatherReports.
|
|
34
|
-
- `ablo.weatherReports.
|
|
35
|
-
- `ablo.weatherReports.
|
|
36
|
-
- `ablo.weatherReports.
|
|
37
|
-
- `ablo.weatherReports.
|
|
47
|
+
- `ablo.weatherReports.retrieve({ id })` reads one row asynchronously (server read).
|
|
48
|
+
- `ablo.weatherReports.list({ where })` reads a collection asynchronously (server read).
|
|
49
|
+
- `ablo.weatherReports.get(id)` reads one row synchronously from the local graph.
|
|
50
|
+
- `ablo.weatherReports.create({ data })` creates a row.
|
|
51
|
+
- `ablo.weatherReports.update({ id, data, ...options })` updates a row.
|
|
52
|
+
- `ablo.weatherReports.delete({ id, ...options })` deletes a row.
|
|
38
53
|
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
`retrieve`/`list` and `get`/`getAll`/`getCount` are not aliases. Use
|
|
55
|
+
`retrieve({ id })` or `list({ where })` when the row may not be local yet — they
|
|
56
|
+
hydrate pool → IndexedDB → network. Use `get(id)` / `getAll({ where })` /
|
|
57
|
+
`getCount({ where })` for a cheap synchronous snapshot of what is already in
|
|
58
|
+
the local graph.
|
|
42
59
|
|
|
43
60
|
| Method | Returns | Use when |
|
|
44
61
|
|---|---|---|
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
| `retrieve({ id })` | `Promise<T \| undefined>` | You need one row, hydrating from local store and server. |
|
|
63
|
+
| `list({ where })` | `Promise<T[]>` | You need to hydrate a collection from local store and server. |
|
|
64
|
+
| `get(id)` | `T \| undefined` | You want a synchronous snapshot of one local row. |
|
|
65
|
+
| `getAll(options?)` | `T[]` | You want a synchronous snapshot of a local collection. |
|
|
66
|
+
| `getCount(options?)` | `number` | You want a synchronous count of local rows. |
|
|
67
|
+
| `create({ data, ...options })` | `Promise<T>` | You want to create through the schema model. |
|
|
68
|
+
| `update({ id, data, ...options })` | `Promise<T>` | You want to update through the schema model. |
|
|
69
|
+
| `delete({ id, ...options })` | `Promise<void>` | You want to delete through the schema model. |
|
|
70
|
+
|
|
71
|
+
`retrieve`, `list`, `create`, `update`, and `delete` are the main path — they go
|
|
72
|
+
through the server. `get` / `getAll` / `getCount` are **synchronous reads**
|
|
73
|
+
off the rows a session has already synced, so a cheap re-read needs no round-trip.
|
|
56
74
|
|
|
57
75
|
## Protected Writes
|
|
58
76
|
|
|
@@ -61,11 +79,13 @@ Use `snapshot` when a write should reject if the row changed mid-flight:
|
|
|
61
79
|
```ts
|
|
62
80
|
const snap = ablo.snapshot({ weatherReports: 'report_stockholm' });
|
|
63
81
|
|
|
64
|
-
await ablo.weatherReports.update(
|
|
65
|
-
'report_stockholm',
|
|
66
|
-
{ status: 'ready' },
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
await ablo.weatherReports.update({
|
|
83
|
+
id: 'report_stockholm',
|
|
84
|
+
data: { status: 'ready' },
|
|
85
|
+
readAt: snap.stamp,
|
|
86
|
+
onStale: 'reject',
|
|
87
|
+
wait: 'confirmed',
|
|
88
|
+
});
|
|
69
89
|
```
|
|
70
90
|
|
|
71
91
|
Protected write options:
|
|
@@ -80,23 +100,26 @@ Protected write options:
|
|
|
80
100
|
|
|
81
101
|
## Claims
|
|
82
102
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
Before anyone writes a row, they can claim it so other people and agents see
|
|
104
|
+
who is editing it in real time. Claims don't lock. If another writer holds the
|
|
105
|
+
row, `claim` waits for them, re-reads the fresh row, then hands it to you — so
|
|
106
|
+
two writers serialize instead of clobbering. A claim is temporary: it expires
|
|
107
|
+
on its own if the holder stops, and is never saved as a row.
|
|
87
108
|
|
|
88
|
-
|
|
89
|
-
`ablo.<model>.claim(id
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
You coordinate a row with calls on its model, beside `create`/`update`/`retrieve`:
|
|
110
|
+
`ablo.<model>.claim({ id })` takes the claim and returns a handle,
|
|
111
|
+
`ablo.<model>.claim.state({ id })` reads who currently holds it (synchronous, never
|
|
112
|
+
blocks), and `ablo.<model>.claim.release({ id })` releases it early. The full
|
|
113
|
+
coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
114
|
+
`claim.release({ id })` / `claim.reorder({ id, order })` hanging off `claim`.
|
|
92
115
|
|
|
93
116
|
### The Claim State Object
|
|
94
117
|
|
|
95
118
|
| Field | Type | Description |
|
|
96
119
|
|---|---|---|
|
|
97
|
-
| `object` | `'
|
|
120
|
+
| `object` | `'intent'` | String representing the object's type. |
|
|
98
121
|
| `id` | string | Unique identifier for the claim. |
|
|
99
|
-
| `status` | `'active' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. |
|
|
122
|
+
| `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
|
|
100
123
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
101
124
|
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
102
125
|
| `heldBy` | string | Participant id holding the claim. |
|
|
@@ -105,7 +128,7 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
105
128
|
|
|
106
129
|
```json
|
|
107
130
|
{
|
|
108
|
-
"object": "
|
|
131
|
+
"object": "intent",
|
|
109
132
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
110
133
|
"status": "active",
|
|
111
134
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
|
@@ -119,7 +142,7 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
119
142
|
### Lifecycle
|
|
120
143
|
|
|
121
144
|
```
|
|
122
|
-
claim(id)
|
|
145
|
+
claim({ id }) update({ id }) lands
|
|
123
146
|
(free) ───────────▶ active ───────────────────────▶ committed
|
|
124
147
|
│
|
|
125
148
|
┌───────────┴───────────┐
|
|
@@ -128,45 +151,82 @@ early. Claims are **advisory** — they serialize on contention rather than lock
|
|
|
128
151
|
(release w/o write) (TTL; holder died)
|
|
129
152
|
```
|
|
130
153
|
|
|
131
|
-
A target is free when `ablo.<model>.
|
|
132
|
-
states drop out of the live stream, so a present claim is active
|
|
154
|
+
A target is free when `ablo.<model>.claim.state({ id })` is `null`. Terminal
|
|
155
|
+
states drop out of the live stream, so a present claim is either `active` (the
|
|
156
|
+
holder) or `queued` (waiting in the FIFO line behind the holder; see
|
|
157
|
+
`claim.queue({ id })`).
|
|
133
158
|
|
|
134
159
|
### Reading and claiming
|
|
135
160
|
|
|
136
|
-
`
|
|
137
|
-
returns the live claim state object (or `null`). `claim(id
|
|
138
|
-
it
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`ifClaimed: '
|
|
161
|
+
`claim.state({ id })` is the read side for observers: synchronous, never blocks, and
|
|
162
|
+
returns the live claim state object (or `null`). `claim({ id })` is the write
|
|
163
|
+
side: it takes the claim and returns a `ClaimHandle`. Claims don't lock — if someone else
|
|
164
|
+
already holds the row, `claim` waits for them to finish, re-reads the fresh row,
|
|
165
|
+
then hands it to you, so you always proceed from current state. Default reads
|
|
166
|
+
return the row even while someone is mid-edit; if a server read should not
|
|
167
|
+
return a row while it's claimed, pass `ifClaimed: 'wait'` to wait for the claim
|
|
168
|
+
to clear, or `ifClaimed: 'fail'` to error out instead.
|
|
143
169
|
|
|
144
170
|
```ts
|
|
145
|
-
const claim = ablo.weatherReports.
|
|
171
|
+
const claim = ablo.weatherReports.claim.state({ id: 'report_stockholm' });
|
|
146
172
|
if (claim) {
|
|
147
173
|
claim.heldBy;
|
|
148
174
|
claim.action;
|
|
149
175
|
}
|
|
150
176
|
|
|
151
|
-
const
|
|
152
|
-
'report_stockholm',
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
177
|
+
const handle = await ablo.weatherReports.claim({
|
|
178
|
+
id: 'report_stockholm',
|
|
179
|
+
action: 'editing',
|
|
180
|
+
ttl: '2m',
|
|
181
|
+
});
|
|
182
|
+
await ablo.weatherReports.update({ id: handle.data.id, data: { status: 'ready' } });
|
|
183
|
+
await handle.release();
|
|
156
184
|
```
|
|
157
185
|
|
|
158
|
-
Writes go through the normal
|
|
159
|
-
a claim on `id`, that `update`
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
need to release early.
|
|
186
|
+
Writes go through the normal `ablo.<model>.update({ id, data })`. While you hold
|
|
187
|
+
a claim on `id`, that `update` rejects with `AbloStaleContextError` if the row
|
|
188
|
+
changed underneath you since you took the claim, so you re-read before retrying.
|
|
189
|
+
Call `handle.release()` (or `ablo.weatherReports.claim.release({ id })`) to release
|
|
190
|
+
the claim when your work is done.
|
|
164
191
|
|
|
165
192
|
## Agent
|
|
166
193
|
|
|
167
194
|
Most agents should import the same schema as the app and call
|
|
168
|
-
`ablo.<model>.
|
|
169
|
-
`ablo.<model>.update(
|
|
195
|
+
`ablo.<model>.list(...)`, `ablo.<model>.claim({ id })`, and
|
|
196
|
+
`ablo.<model>.update({ id, data })`.
|
|
197
|
+
|
|
198
|
+
## HTTP API
|
|
199
|
+
|
|
200
|
+
The SDK is a convenience wrapper over a model-scoped HTTP surface — the same
|
|
201
|
+
noun (`model`) and verbs as `ablo.<model>.…`. Non-JS callers (or curl) use it
|
|
202
|
+
directly. The table below shows the shape with `{model}` as a placeholder; the
|
|
203
|
+
[OpenAPI spec](./openapi.json) expands it into one **typed** path per model
|
|
204
|
+
(`/v1/models/task`, `/v1/models/deck`, …, generated from your schema) so each
|
|
205
|
+
endpoint documents that model's real field contract instead of a generic blob.
|
|
206
|
+
|
|
207
|
+
| SDK call | HTTP |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `ablo.<model>.create({ data })` | `POST /v1/models/{model}` |
|
|
210
|
+
| `ablo.<model>.list({ where })` | `GET /v1/models/{model}` |
|
|
211
|
+
| `ablo.<model>.retrieve({ id })` | `GET /v1/models/{model}/{id}` |
|
|
212
|
+
| `ablo.<model>.update({ id, data })` | `PATCH /v1/models/{model}/{id}` |
|
|
213
|
+
| `ablo.<model>.delete({ id })` | `DELETE /v1/models/{model}/{id}` |
|
|
214
|
+
| `ablo.<model>.claim({ id })` | `POST /v1/models/{model}/{id}/claim` |
|
|
215
|
+
| (release a claim) | `DELETE /v1/models/{model}/{id}/claim` |
|
|
216
|
+
|
|
217
|
+
Auth is a bearer API key: `Authorization: Bearer sk_…`. Mutations take an
|
|
218
|
+
`Idempotency-Key` header — derive it from the business event, not a random
|
|
219
|
+
value, so a retry never double-writes. Writes return a `CommitReceipt`; a
|
|
220
|
+
rejected write carries an error `code` (e.g. `stale_context`, `intent_conflict`)
|
|
221
|
+
to act on. `GET /v1/models/{model}` is cursor-paginated (`limit`, `order`,
|
|
222
|
+
`order_by`, `starting_after`) and returns `{ data, has_more, next_cursor }`.
|
|
223
|
+
|
|
224
|
+
`POST /v1/commits` remains the path for **atomic multi-op** writes (several
|
|
225
|
+
operations across rows/models that must commit together) — the per-model routes
|
|
226
|
+
above are the one-record path. Both run the identical guarded-write engine.
|
|
227
|
+
|
|
228
|
+
The [coordination MCP server](./mcp.md) (`@ablo/mcp`) is this same surface
|
|
229
|
+
rendered as agent tools.
|
|
170
230
|
|
|
171
231
|
## Errors
|
|
172
232
|
|