@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/CHANGELOG.md +60 -0
- package/README.md +1 -1
- package/dist/ai-sdk.js.map +1 -1
- package/dist/sessions.d.ts +3 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +2 -0
- package/dist/sessions.js.map +1 -0
- package/docs/agent-messaging.md +3 -2
- package/docs/agents.md +49 -32
- package/docs/api-keys.md +18 -12
- package/docs/client-behavior.md +6 -4
- package/docs/coordination.md +6 -3
- package/docs/customer-organizations.md +8 -19
- package/docs/deployment.md +7 -6
- package/docs/examples/existing-python-backend.md +15 -18
- package/docs/examples/nextjs.md +33 -66
- package/docs/examples/scoped-agent.md +4 -3
- package/docs/examples/server-agent.md +4 -3
- package/docs/groups.md +11 -12
- package/docs/identity.md +33 -29
- package/docs/integration-guide.md +12 -18
- package/docs/options.md +80 -29
- package/docs/react.md +8 -52
- package/docs/security.md +1 -1
- package/docs/sessions.md +97 -69
- package/docs/transports.md +124 -0
- package/examples/README.md +7 -0
- package/examples/terminal-showcase/index.ts +219 -0
- package/examples/terminal-showcase/schema.ts +12 -0
- package/llms.txt +9 -9
- package/package.json +10 -5
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
|
-
|
|
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`
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
251
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
//
|
|
20
|
-
const
|
|
21
|
-
|
|
24
|
+
// An agent session — hand 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
|
-
`
|
|
28
|
-
|
|
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.
|
|
47
|
-
|
|
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 {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
`
|
|
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 `
|
|
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
|
-
|
|
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? `
|
|
96
|
-
returns
|
|
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
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
import { schema } from './ablo.schema';
|
|
128
|
-
|
|
129
|
-
const recordWriterCan = {
|
|
142
|
+
const workerAccess = {
|
|
130
143
|
records: ['update'],
|
|
131
|
-
}
|
|
144
|
+
} as const;
|
|
132
145
|
|
|
133
|
-
const
|
|
146
|
+
const session = await sessions.create({
|
|
147
|
+
agent: { id: crypto.randomUUID() },
|
|
148
|
+
can: workerAccess,
|
|
149
|
+
});
|
|
134
150
|
```
|
|
135
151
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
| `
|
|
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)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
its
|
|
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
|
|
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
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 `
|
|
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
|
|
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 | `
|
|
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.
|
package/examples/README.md
CHANGED
|
@@ -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
|