@abloatai/ablo 0.59.2 → 0.60.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/docs/react.md CHANGED
@@ -33,7 +33,7 @@ import { schema } from '@/ablo/schema';
33
33
  // from your own server route (see Identity below).
34
34
  export const ablo = Ablo({
35
35
  schema,
36
- authEndpoint: '/api/ablo-session',
36
+ session: { endpoint: '/api/ablo-session' },
37
37
  });
38
38
 
39
39
  // The typed binding: capture the schema once, and every component imports
@@ -188,52 +188,11 @@ imperative work after an event or effect.
188
188
 
189
189
  See [API reference](/docs/api) for the full options surface.
190
190
 
191
- ## useJoin: scoped presence + read interest
192
-
193
- `useJoin` is the React form of `ablo.<model>.join`. It joins multiplayer for a
194
- scope on the engine's existing socket (one TCP connection, N logical
195
- sub-syncgroup participants) and returns the reactive participant facade. Use it
196
- when a mount should both *see* who else is on an entity and, optionally, declare
197
- write interest in it.
198
-
199
- ```tsx
200
- 'use client';
201
-
202
- import { useJoin } from '@abloatai/ablo/react';
203
-
204
- export function CollectionPresence({ workspaceId }: { workspaceId: string }) {
205
- const { peers, claims, status } = useJoin({
206
- scope: { entryCollections: workspaceId },
207
- claim: true, // I intend to write — pin the scope + let peers observe the claim
208
- hydrate: true, // backfill the workspace's current rows if not already loaded
209
- });
210
-
211
- if (status !== 'joined') return <span>connecting…</span>;
212
- return <span>{peers.length} other{peers.length === 1 ? '' : 's'} here</span>;
213
- }
214
- ```
215
-
216
- Options (`UseJoinOptions`):
217
-
218
- | Option | Default | Effect |
219
- | --- | --- | --- |
220
- | `scope` |: | Model-form scope (`{ entryCollections: id }`), resolved through the schema. Omit for engine-wide. |
221
- | `claim` | `false` | Acquire a write-claim on the scope (sent so peers observe it; pins the scope so it never warm-drops while held). A viewer is not a claimant: leave `false` for read-only. |
222
- | `hydrate` | `false` | Backfill the scope's current rows into the pool once on enter, then keep them fresh via the live tail. Set `true` for deep-linked / never-opened entities. Single-flight; soft-fails. |
223
- | `ttlSeconds` |: | Lease TTL for the scope claim. |
224
- | `paused` | `false` | Tear down and don't re-join while true. |
225
-
226
- Returns (`UseJoinReturn`): `{ participant, peers, claims, status, error }`.
227
- `peers` is everyone else on the scope's sync groups; `claims` is their active
228
- write-claims; `status` is the join lifecycle. Auto-cleans up on unmount or when
229
- `paused` flips true.
230
-
231
191
  ## usePeers: read-only presence
232
192
 
233
- `usePeers` is a *pure reader* of the presence stream already flowing on the
234
- connection. Unlike `useJoin`, it does **not** enter/leave a scope (no
235
- `update_subscription`, no warm-TTL churn) — so reading it never changes what the
236
- connection is subscribed to.
193
+ `usePeers` reads the presence stream already flowing for the client's scoped
194
+ groups. It does not create a second membership or lease and does not change the
195
+ connection's subscriptions.
237
196
 
238
197
  ```tsx
239
198
  'use client';
@@ -247,15 +206,12 @@ export function CursorBroadcaster({ workspaceId }: { workspaceId: string }) {
247
206
  }
248
207
  ```
249
208
 
250
- Pass `scope` to narrow to a sync group's peers, or omit it for everyone on the
251
- engine's groups. Returns `ReadonlyArray<Peer>`, where each `Peer` carries
209
+ Pass a schema-shaped group scope to filter the visible peers, or omit it for
210
+ everyone on the client's groups. Returns `ReadonlyArray<Peer>`, where each `Peer` carries
252
211
  `participantKind` (`'user' | 'agent' | 'system'`), `participantId`, optional
253
212
  `label`, `syncGroups`, `activity`, `lastActive`, and optional `activeClaims`.
254
-
255
- Reach for `usePeers` (not a second `useJoin`) when some **other** mount already
256
- owns the scope's read interest — scope `leave` is not reference-counted, so a
257
- second `useJoin` on the same scope would warm-drop the owner's subscription on
258
- unmount.
213
+ Use `ablo.<model>.claim` when the caller needs exclusion; reading presence does
214
+ not claim anything.
259
215
 
260
216
  ## Next.js
261
217
 
package/docs/security.md CHANGED
@@ -13,7 +13,7 @@ credential, normally supplied through `ABLO_API_KEY`. Never include it in a
13
13
  browser bundle or agent-generated output.
14
14
 
15
15
  Browsers use either a publishable read-only `pk_` credential or a short-lived,
16
- scoped session minted by your backend through `authEndpoint`. See [API
16
+ scoped session minted by your backend through `session.endpoint`. See [API
17
17
  Keys](./api-keys.md) and [Sessions](./sessions.md) for the credential classes and
18
18
  minting flow.
19
19
 
package/docs/sessions.md CHANGED
@@ -7,26 +7,31 @@ hands to one actor — a signed-in **person's browser** or a scoped **agent**. I
7
7
  the same primitive in both cases (backend-minted, short-lived, scoped); the only
8
8
  difference is the subject and how much authority it carries.
9
9
 
10
- One resource mints both:
10
+ One server-only issuer mints both. It is separate from `Ablo(...)` so the
11
+ participant client keeps every schema model name, including `ablo.sessions`:
11
12
 
12
13
  ```ts Your backend (sk_)
14
+ import Sessions from '@abloatai/ablo/sessions';
15
+
16
+ const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
17
+
13
18
  // A logged-in person's browser session — only the operations this UI needs.
14
- const userSession = await ablo.sessions.create({
19
+ const userSession = await sessions.create({
15
20
  user: { id: currentUser.id },
16
21
  can: { records: ['read', 'update'], workspaces: ['read'] },
17
22
  });
18
23
 
19
- // Recommended agent pathreturns a ready, scoped client.
20
- const agent = await ablo.agents.create({
21
- name: 'record-writer',
24
+ // An agent sessionhand its rk_ to the agent runtime.
25
+ const agentSession = await sessions.create({
26
+ agent: { id: crypto.randomUUID() },
22
27
  can: { records: ['read', 'update'], workspaces: ['read'] },
28
+ userMeta: { name: 'record-writer' },
23
29
  });
24
30
  ```
25
31
 
26
- `sessions.create({ user, can })` mints an `ek_` (ephemeral key).
27
- `agents.create({ can })` mints and manages an `rk_` (restricted key). Use the
28
- lower-level `sessions.create({ agent, can })` only when another runtime needs
29
- the raw agent token.
32
+ `sessions.create({ user, can })` mints an `ek_` (ephemeral key), while
33
+ `sessions.create({ agent, can })` mints an `rk_` (restricted key). There is one
34
+ issuance API; the subject selects the credential kind and attribution.
30
35
 
31
36
  It exists because of one rule: **the browser can never hold a secret.** Your
32
37
  `sk_` lives on the server; the browser only ever holds a minted session token
@@ -43,30 +48,39 @@ session token *is* that assertion: "this connection is acting as `U`, in org
43
48
 
44
49
  ## End-user sessions (`ek_`)
45
50
 
46
- For a logged-in person using your app. Mint on a backend route that has already
47
- authenticated the user:
51
+ For a logged-in person using your app. Mount a session handler on a backend
52
+ route and connect it to the authentication you already use:
48
53
 
49
54
  ```ts Your backend route (session-authed)
50
- import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
51
-
52
- const { token, expiresAt } = await ablo.sessions.create({
53
- user: { id: currentUser.id }, // who the session acts as
54
- can: { records: ['read', 'update'] },
55
- // syncGroups: [...], // optional; defaults to the user's org + user
55
+ import { headers } from 'next/headers';
56
+ import { auth } from '@/lib/auth';
57
+ import { sessions } from '@/ablo/sessions';
58
+
59
+ export const POST = sessions.handler({
60
+ async authenticate() {
61
+ const session = await auth.api.getSession({ headers: await headers() });
62
+ return session?.user ?? null;
63
+ },
64
+ async grant({ principal: user }) {
65
+ const membership = await findActiveMembership(user.id);
66
+ if (!membership) return null;
67
+
68
+ return {
69
+ user: { id: user.id },
70
+ groups: [`workspace:${membership.workspaceId}`],
71
+ can: { records: ['read', 'update'] },
72
+ };
73
+ },
56
74
  });
57
- return Response.json(
58
- credentialEndpointSuccessSchema.parse({
59
- token,
60
- expiresAt,
61
- credentialKind: 'ephemeral',
62
- }),
63
- { headers: { 'Cache-Control': 'no-store' } },
64
- );
65
75
  ```
66
76
 
67
- `can` is required. The mint fails closed when the grant is absent or empty.
77
+ `authenticate` adapts Better Auth (or any other auth system) to an application
78
+ principal. `grant` derives Ablo identity, groups, and permissions server-side.
79
+ Returning `null` from either step fails closed. The handler owns same-origin
80
+ checks, no-store responses, and the credential envelope; the application never
81
+ handles the token. `can` is required and cannot be empty.
68
82
 
69
- Point a browser `Ablo` client's `authEndpoint` at that route, and pass
83
+ Point a browser `Ablo` client's `session.endpoint` at that route, and pass
70
84
  the **instance** to [`<AbloProvider>`](/react). The client fetches the token,
71
85
  opens the connection, and re-mints before expiry — your app writes no token
72
86
  plumbing:
@@ -80,7 +94,7 @@ import { schema } from '@/ablo.schema';
80
94
 
81
95
  const ablo = Ablo({
82
96
  schema,
83
- authEndpoint: '/api/ablo-session',
97
+ session: { endpoint: '/api/ablo-session' },
84
98
  });
85
99
 
86
100
  export function Providers({ children }: { children: React.ReactNode }) {
@@ -92,8 +106,8 @@ The client owns auth, the credential lifecycle, and the connection; the provider
92
106
  is only the thin reactive binding over it.
93
107
  Build the client **once** at module scope — a new instance per render tears down
94
108
  the socket. Need custom headers or a
95
- body on the exchange? `authEndpoint` also accepts an async resolver that
96
- returns the token.
109
+ body on the exchange? Pass an async `session` provider that performs the custom
110
+ request and returns its credential response.
97
111
 
98
112
  ## Agents (`rk_`)
99
113
 
@@ -102,47 +116,61 @@ operations. The `can` map is the permission boundary, and it's **typed against
102
116
  your schema** — the model keys are your schema's models, so a typo is a compile
103
117
  error, not a silent over-grant:
104
118
 
119
+ The examples below reuse the server-only `sessions` issuer constructed above.
120
+
105
121
  ```ts
106
- const agent = await ablo.agents.create({
107
- name: 'record-writer',
122
+ const session = await sessions.create({
123
+ agent: { id: crypto.randomUUID() },
108
124
  can: { records: ['update'] }, // typed off the schema — no magic strings
109
125
  ttlSeconds: 600,
126
+ userMeta: { name: 'record-writer' },
110
127
  });
111
128
 
129
+ const agent = Ablo({ schema, session, transport: 'http' });
112
130
  await agent.records.update({ id, data });
113
131
  await agent.dispose();
114
132
  ```
115
133
 
116
- The returned client refreshes its own short-lived credential. A write grant
117
- automatically includes the corresponding read, so
134
+ The session is the credential; construct the agent client in the runtime that
135
+ will use it. A write grant automatically includes the corresponding read, so
118
136
  `can: { records: ['update'] }` is enforced as `record.update` plus `record.read`.
119
137
  Operations are `'read' | 'create' | 'update' | 'delete'`.
120
138
 
121
- For a reusable grant, use TypeScript's `satisfies`. It checks the object against
122
- the schema while preserving its narrow literals—there is no string parser or
123
- second permission model:
139
+ Give reusable access a domain name and keep it next to the worker that owns it:
124
140
 
125
141
  ```ts
126
- import type { CapabilityGrant } from '@abloatai/ablo/auth';
127
- import { schema } from './ablo.schema';
128
-
129
- const recordWriterCan = {
142
+ const workerAccess = {
130
143
  records: ['update'],
131
- } satisfies CapabilityGrant<typeof schema>;
144
+ } as const;
132
145
 
133
- const agent = await ablo.agents.create({ can: recordWriterCan });
146
+ const session = await sessions.create({
147
+ agent: { id: crypto.randomUUID() },
148
+ can: workerAccess,
149
+ });
134
150
  ```
135
151
 
136
- `documents` instead of `records`, or `'write'` instead of `'update'`, is a compile
137
- error. At runtime the SDK parses the same grant with the schema-bound Zod
138
- contract before minting, and the server validates it again against the active
139
- pushed schema.
152
+ The SDK checks the model names and operations against the bound schema before
153
+ minting, and the server validates them again against the active pushed schema.
140
154
 
141
- <Note>
142
- Use `sessions.create({ agent, can })` when a separate runtime, MCP process, or
143
- protocol integration needs the raw token. For an agent running in the current
144
- server process, prefer `agents.create({ can })`.
145
- </Note>
155
+ For a long-running agent, pass an async session provider. It re-mints the same
156
+ logical identity through the canonical resource; the client caches each result
157
+ until it approaches `expiresAt` and uses the replacement for HTTP requests and
158
+ WebSocket reconnects:
159
+
160
+ ```ts
161
+ const session = () => sessions.create({
162
+ agent: { id: stableWorkerId },
163
+ can: workerAccess,
164
+ groups: [workspaceGroup],
165
+ });
166
+
167
+ const agent = Ablo({ schema, session });
168
+ ```
169
+
170
+ A static `session` resource does not invent authority to renew itself. It lives
171
+ until `expiresAt`; use a provider when the client must outlive that credential.
172
+ Session clients use one reconnecting WebSocket by default. Pass
173
+ `transport: 'http'` only for bounded scoped work that must not hold a socket.
146
174
 
147
175
  ## Mint
148
176
 
@@ -156,41 +184,42 @@ for the actor.
156
184
  | `can` | both | Required non-empty per-model operation allowlist, typed off the schema. |
157
185
  | `organizationId` | user | Mint into a customer organization instead of the key's own. Requires `organization:act-as`. |
158
186
  | `schemaProject` | user | Override the schema project for a cross-org mint. Usually omitted because the owning key's project is the default. |
159
- | `syncGroups` | both | Narrow the session below its default scope. Omit to inherit. |
187
+ | `groups` | both | Narrow the session below its default scope. Omit to inherit. |
160
188
  | `ttlSeconds` | both | Lifetime in seconds. Defaults to `900` (15m). |
161
189
  | `userMeta` | both | Opaque identity blob echoed back to the client. |
162
190
 
163
191
  ## Lifecycle
164
192
 
165
- Sessions are **short-lived by design** (~15 minutes) and, for browsers,
166
- **auto-refreshed** — the provider re-mints ahead of expiry, so a session never
167
- drops at the boundary. Signing out stops refresh and the old token expires on
168
- its own.
193
+ Sessions are **short-lived by design** (~15 minutes). A renewable browser or
194
+ agent provider pre-mints ahead of expiry and reconnects with the replacement,
195
+ so the logical session and durable observation continue even though an
196
+ individual socket may be replaced. A static session ends at its credential's
197
+ expiry. Signing out stops refresh and the old token expires on its own.
169
198
 
170
199
  Revoke immediately when a token is exposed or an actor loses access:
171
200
 
172
201
  ```ts
173
- await ablo.sessions.revoke({ id: session.id });
202
+ await sessions.revoke({ id: session.id });
174
203
  ```
175
204
 
176
205
  Agent sessions can rotate with overlap so a worker can adopt the replacement
177
206
  before the previous token expires:
178
207
 
179
208
  ```ts
180
- const replacement = await ablo.sessions.rotate({
209
+ const replacement = await sessions.rotate({
181
210
  id: session.id,
182
211
  graceSeconds: 300,
183
212
  ttlSeconds: 900,
184
213
  });
185
214
  ```
186
215
 
187
- Browser `ek_` sessions rotate through `authEndpoint`; do not distribute rotated
216
+ Browser `ek_` sessions rotate through `session.endpoint`; do not distribute rotated
188
217
  browser tokens manually.
189
218
 
190
219
  ### Offline & sign-out
191
220
 
192
221
  The short session token is **not** your user's login — it's a minutes-long
193
- credential layered on top of whatever long-lived auth your `authEndpoint`
222
+ credential layered on top of whatever long-lived auth your `session.endpoint`
194
223
  already enforces (your own session cookie, an IdP, etc.). The provider keeps
195
224
  those two lifetimes separate, which means:
196
225
 
@@ -200,7 +229,7 @@ those two lifetimes separate, which means:
200
229
  connectivity or tab focus returns. The user stays signed in for as long as
201
230
  your underlying session is valid, however brief or long the network drop.
202
231
  - **The user is signed out only when the underlying session is genuinely
203
- gone** — your `authEndpoint` responds `401` with the canonical
232
+ gone** — your session endpoint responds `401` with the canonical
204
233
  `{ error: { code: 'session_expired' } }` body. An unrelated `401` or `403`
205
234
  is a policy/configuration failure, not proof that the login ended.
206
235
 
@@ -209,10 +238,9 @@ rejection of the *long-lived* credential ends the session — a network failure
209
238
  never does.
210
239
 
211
240
  <Note>
212
- Use `credentialEndpointSuccessSchema` and `credentialEndpointErrorSchema` from
213
- `@abloatai/ablo/auth` on the route. Return `session_expired` only when the
214
- application login is actually gone, and let network/`5xx` failures surface as
215
- errors.
241
+ `sessions.handler` returns `session_expired` only when `authenticate` reports
242
+ that the application login is gone. Exceptions and network failures remain
243
+ transient errors; they do not sign the user out.
216
244
  </Note>
217
245
 
218
246
  ## Scope
@@ -221,7 +249,7 @@ A user session carries the user's **base** sync-groups (`org:`/`user:`/`team:`),
221
249
  derived from the identity you minted it for. **Dynamic, relation-driven
222
250
  membership** (e.g. a `archive:<id>` the user was just added to) is resolved
223
251
  **server-side at connect** and unioned on top — so scope stays live, not frozen
224
- at mint time. Pass `syncGroups` only when you want to *narrow* below the default.
252
+ at mint time. Pass `groups` only when you want to *narrow* below the default.
225
253
 
226
254
  ## Your schema, your users (the default)
227
255
 
@@ -268,7 +296,7 @@ project while its *data* stays in the customer's own org:
268
296
 
269
297
  ```ts
270
298
  const ablo = Ablo({ schema, apiKey: process.env.ABLO_PLATFORM_KEY });
271
- const { token } = await ablo.sessions.create({
299
+ const { token } = await sessions.create({
272
300
  user: { id: userId },
273
301
  organizationId, // DATA → this customer's isolated org
274
302
  can: { records: ['read', 'update'] },
@@ -305,5 +333,5 @@ compromise — which is exactly why it never leaves your server.
305
333
  |---|---|---|
306
334
  | For | a **person** in the browser | an **agent** / automation |
307
335
  | Authority | narrow (explicit `can` allowlist) | narrow (explicit `can` allowlist) |
308
- | Mint | `ablo.sessions.create({ user: { id }, can })` | `ablo.sessions.create({ agent: { id }, can })` |
336
+ | Mint | `sessions.create({ user: { id }, can })` | `sessions.create({ agent: { id }, can })` |
309
337
  | Lives where | the user's **browser** | the agent runtime |
@@ -0,0 +1,124 @@
1
+ # Transports
2
+
3
+ > Transport follows identity: API-key services use HTTP and scoped sessions use one multiplexed WebSocket.
4
+
5
+ A session is not a WebSocket. It is the short-lived credential that identifies
6
+ an actor and bounds its authority. `Ablo()` owns the client lifecycle, and the
7
+ credential decides the normal transport; `transport: 'http'` is the explicit
8
+ escape hatch for bounded work that still needs a distinct session identity.
9
+
10
+ | Workload | Select | Lifetime |
11
+ |---|---|---|
12
+ | API-key route handler, job, cron, or serverless invocation | HTTP (default) | No persistent connection; dispose after bounded work. |
13
+ | Wait for one captured context to become stale | HTTP + `context().onChange` | One POST/SSE response, closed after the first matching change or cancellation. |
14
+ | Scoped user or agent session | WebSocket (default) | One reconnecting connection per client and Ablo cell, held until `dispose()`. |
15
+ | Human reactive interface | `humans()` / React client | One WebSocket owned by the long-lived client instance. |
16
+
17
+ ## API-key services use HTTP
18
+
19
+ An API key identifies trusted service work, so no session or socket is needed:
20
+
21
+ ```ts
22
+ const worker = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
23
+ try {
24
+ await run(worker);
25
+ } finally {
26
+ await worker.dispose();
27
+ }
28
+ ```
29
+
30
+ For bounded work that must have a distinct agent identity, create a session and
31
+ explicitly select HTTP:
32
+
33
+ ```ts
34
+ import Sessions from '@abloatai/ablo/sessions';
35
+
36
+ const sessions = Sessions({ schema, apiKey: process.env.ABLO_API_KEY });
37
+ const session = await sessions.create({
38
+ agent: { id: runId },
39
+ can: workerAccess,
40
+ groups: [workspaceGroup],
41
+ });
42
+
43
+ const worker = Ablo({ schema, session, transport: 'http' });
44
+ ```
45
+
46
+ Reads and administrative resources remain HTTP even when a session client also
47
+ has a WebSocket. The separate `Sessions(...)` issuer always uses HTTP. The
48
+ public model API does not change with the carrier.
49
+
50
+ ## A session owns one reconnecting WebSocket
51
+
52
+ Use a session provider when the client must outlive one credential:
53
+
54
+ ```ts
55
+ const session = () => sessions.create({
56
+ agent: { id: stableWorkerId },
57
+ can: workerAccess,
58
+ groups: [workspaceGroup],
59
+ });
60
+
61
+ const agent = Ablo({
62
+ schema,
63
+ session,
64
+ groups: [workspaceGroup],
65
+ cursorStore,
66
+ });
67
+
68
+ await agent.ready();
69
+ try {
70
+ for await (const delta of agent.observe()) {
71
+ await apply(delta);
72
+ await delta.checkpoint();
73
+ }
74
+ } finally {
75
+ await agent.dispose();
76
+ }
77
+ ```
78
+
79
+ The provider represents one stable actor and grant. The client caches its
80
+ returned session, pre-mints a replacement before `expiresAt`, uses the newest
81
+ credential for later requests and reconnects, and prevents HTTP bootstrap plus
82
+ WebSocket setup from minting twice. A transient mint failure retries; only a
83
+ provider returning no session ends the logical session. Do not reuse one
84
+ provider across different actors, workspaces, or capability sets.
85
+
86
+ The socket reconnects after transient disconnects and requests replay from the
87
+ last durably checkpointed cursor. Uncheckpointed deltas may be delivered again.
88
+ An active observer has a bounded backlog and fails explicitly if its consumer
89
+ cannot keep up.
90
+
91
+ The session survives socket replacement, but socket-bound operations have
92
+ deliberate boundaries:
93
+
94
+ | State | After reconnect |
95
+ |---|---|
96
+ | Durable observation cursor | Replayed from the last checkpoint; duplicates before the checkpoint are discarded. |
97
+ | Active observer | Continues across a renewable credential expiry or transient socket loss. |
98
+ | Last acknowledged subscription | Restored on the replacement socket. |
99
+ | Presence | Re-announced after the replacement socket opens. |
100
+ | Commit awaiting a receipt | Rejects as `commit_no_result`; Ablo never guesses whether the server accepted it. Retry with the same idempotency key. |
101
+ | Row claim or queued claim | Ends with the socket. Re-read and acquire a fresh claim after reconnecting. |
102
+
103
+ A static session cannot renew itself. When its bearer expires, the session is
104
+ terminal and operations reject instead of reconnecting forever with the same
105
+ credential.
106
+
107
+ ## Where SSE fits
108
+
109
+ SSE is not the session transport. On an HTTP client,
110
+ `context().onChange` opens one authenticated POST/SSE request and closes it when
111
+ one captured dependency changes. On a WebSocket client, the same operation
112
+ reuses the existing connection, so a long-running agent does not open a
113
+ side-channel SSE stream.
114
+
115
+ ## Groups are two related boundaries
116
+
117
+ `sessions.create({ groups })` narrows what the credential may access.
118
+ `Ablo({ session, groups })` selects the initial subset that the
119
+ connection observes. Connection groups can narrow delivery but can never widen
120
+ the session's authority. The wire protocol still calls this field
121
+ `syncGroups`; that name does not escape into the application-facing options.
122
+
123
+ See [Sessions](./sessions.md) for issuance and renewal, [Agents](./agents.md) for
124
+ worker patterns, and [Options](./options.md) for connection tuning.
@@ -26,6 +26,11 @@ Then:
26
26
  - write with `ablo.weatherReports.update`
27
27
  - dispose the client when the worker finishes
28
28
 
29
+ `terminal-showcase/` is the technical product walkthrough. It
30
+ mints one human and one agent participant, rejects a stale agent write, queues
31
+ the agent behind the human's claim, and prints the durable confirmed commit.
32
+ Nothing in its Ablo path is simulated, and it does not call a model provider.
33
+
29
34
  For read-reason-write work, pass the exact returned rows that informed the
30
35
  decision. Their watermarks stay opaque:
31
36
 
@@ -66,6 +71,8 @@ root and a bare `quickstart.ts` won't be found.
66
71
  ```bash
67
72
  cd packages/ablo
68
73
  ABLO_API_KEY=sk_... npx tsx examples/quickstart.ts
74
+ npx ablo push --schema examples/terminal-showcase/schema.ts
75
+ ABLO_API_KEY=sk_... npx tsx examples/terminal-showcase/index.ts
69
76
  ABLO_API_KEY=sk_... RECORD_ID=record_... npx tsx examples/agent-turn.ts
70
77
  ABLO_API_KEY=sk_... JOB_ID=job_... npx tsx examples/expensive-agent-turn.ts
71
78
  ABLO_API_KEY=sk_... RECORD_ID=record_... npx tsx examples/stale-context-agent-turn.ts