@argentic/chest-sdk 0.1.1 → 0.3.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.
Files changed (57) hide show
  1. package/README.md +573 -37
  2. package/client/index.ts +12 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +85 -0
  5. package/client/src/chest.ts +80 -0
  6. package/client/src/errors.ts +58 -3
  7. package/client/src/events.ts +228 -0
  8. package/client/src/files.ts +93 -88
  9. package/client/src/member.ts +61 -17
  10. package/client/src/members.ts +167 -0
  11. package/client/src/notifications.ts +148 -0
  12. package/client/src/testing.ts +564 -0
  13. package/dist/index.d.ts +5 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +12 -5
  16. package/dist/index.js.map +1 -1
  17. package/dist/src/ai.d.ts +131 -0
  18. package/dist/src/ai.d.ts.map +1 -0
  19. package/dist/src/ai.js +290 -0
  20. package/dist/src/ai.js.map +1 -0
  21. package/dist/src/api.d.ts +11 -0
  22. package/dist/src/api.d.ts.map +1 -0
  23. package/dist/src/api.js +93 -0
  24. package/dist/src/api.js.map +1 -0
  25. package/dist/src/chest.d.ts +10 -0
  26. package/dist/src/chest.d.ts.map +1 -0
  27. package/dist/src/chest.js +49 -0
  28. package/dist/src/chest.js.map +1 -0
  29. package/dist/src/errors.d.ts +19 -0
  30. package/dist/src/errors.d.ts.map +1 -1
  31. package/dist/src/errors.js +49 -3
  32. package/dist/src/errors.js.map +1 -1
  33. package/dist/src/events.d.ts +56 -0
  34. package/dist/src/events.d.ts.map +1 -0
  35. package/dist/src/events.js +193 -0
  36. package/dist/src/events.js.map +1 -0
  37. package/dist/src/files.d.ts +17 -1
  38. package/dist/src/files.d.ts.map +1 -1
  39. package/dist/src/files.js +95 -92
  40. package/dist/src/files.js.map +1 -1
  41. package/dist/src/member.d.ts +10 -3
  42. package/dist/src/member.d.ts.map +1 -1
  43. package/dist/src/member.js +34 -11
  44. package/dist/src/member.js.map +1 -1
  45. package/dist/src/members.d.ts +34 -0
  46. package/dist/src/members.d.ts.map +1 -0
  47. package/dist/src/members.js +146 -0
  48. package/dist/src/members.js.map +1 -0
  49. package/dist/src/notifications.d.ts +25 -0
  50. package/dist/src/notifications.d.ts.map +1 -0
  51. package/dist/src/notifications.js +121 -0
  52. package/dist/src/notifications.js.map +1 -0
  53. package/dist/src/testing.d.ts +101 -0
  54. package/dist/src/testing.d.ts.map +1 -0
  55. package/dist/src/testing.js +552 -0
  56. package/dist/src/testing.js.map +1 -0
  57. package/package.json +40 -4
package/README.md CHANGED
@@ -1,9 +1,13 @@
1
1
  # Chest SDK
2
2
 
3
3
  `@argentic/chest-sdk` is what a server tool (tool contract v2) embeds to talk
4
- with its Chest: the member the Chest asserts on a request, the address of the
5
- tool's own database and its private files. The SDK has no dependency: it only
6
- imports `node:*`.
4
+ with its Chest: the member the Chest asserts on a request, the Chest itself
5
+ (its organization, time zone and language), the other members
6
+ who have the tool, the address of the tool's own database, its private
7
+ files, the badges and notifications it shows members inside the Chest, the
8
+ events of its members' lifecycle, AI models through the Chest — and, for the
9
+ tool's tests, a fake Chest. The SDK has no dependency: it
10
+ only imports `node:*`.
7
11
 
8
12
  ```sh
9
13
  npm install @argentic/chest-sdk
@@ -14,22 +18,35 @@ Node 22 or later. ESM only, compiled JavaScript with its type declarations.
14
18
  ## Imports
15
19
 
16
20
  Each module is its own subpath and pulls in nothing else; the root gives them
17
- all, with the files API as the namespace `files`.
21
+ all, with the files, members, notifications, events and ai APIs as the
22
+ namespaces `files`, `members`, `notifications`, `events` and `ai` (the
23
+ testing module is not in the root).
18
24
 
19
25
  | Import | Gives |
20
26
  |---|---|
21
- | `@argentic/chest-sdk/member` | `member(request)`, type `Member`: the member of a request on the team host of a server tool, read from the `Chest-Member` assertion and verified; `null` without a valid assertion |
27
+ | `@argentic/chest-sdk/member` | `member(request)`, type `Member`: the member of a request on the team host of a server tool, with the language the Chest speaks to them and the zone they work in, read from the `Chest-Member` assertion and verified; `null` without a valid assertion. `memberIdPattern`, `groupIdPattern`, `languagePattern`, `timeZonePattern`: the grammars of the identifiers (`mbr_…`, `grp_…`), of a language and of a zone |
28
+ | `@argentic/chest-sdk/chest` | `chest`, type `Chest`: the Chest the tool runs in — `chest.organization.name`, `chest.timeZone`, `chest.language`, `chest.today()` —, the same for every member, on a request or outside one |
29
+ | `@argentic/chest-sdk/members` | `list`, `get`, `lookup`, `groups.list`, `forget`, types `MemberPage`, `Lookup`, `FormerMember`, `Group`: the members who have the tool (capability `members`, their addresses with `members.email`) |
30
+ | `@argentic/chest-sdk/notifications` | `notify`, `withdraw`, `badge.set`, `badge.setMany`, types `Notice`, `Delivery`, `BadgeCount`, `BadgeWrite`: counters on the tool's tile and items in members' inboxes, inside the Chest (capability `notifications`) |
31
+ | `@argentic/chest-sdk/events` | `handle`, `verify`, `acknowledgeErasure`, `memorySeen`, `erasureIdPattern`, types `ChestEvent`, `MemberUpdated`, `AccessRevoked`, `MemberRemoved`, `MemberErased`, `MemberChange`, `Handlers`, `Seen`: the events of the members' lifecycle the Chest posts to the tool's `/chest-events` (`"receives": ["member.*"]`), verified, deduplicated by id, and the acknowledgment of an erasure |
32
+ | `@argentic/chest-sdk/ai` | `chat`, `embed`, `models`, `usage`, types `Alias`, `Provider`, `ChatMessage`, `ChatTool`, `ToolChoice`, `ResponseFormat`, `ChatOptions`, `ChatResult`, `ChatChunk`, `ToolCall`, `ToolCallDelta`, `Usage`, `EmbedOptions`, `Embeddings`, `AiModel`, `AiUsage`: AI models through the Chest, on the owner's connectors, metered against the tool's monthly cap (capability `ai`) |
22
33
  | `@argentic/chest-sdk/database` | `databaseUrl()`: the address of the tool's own PostgreSQL database (capability `database`) |
23
- | `@argentic/chest-sdk/files` | `put`, `get`, `list`, `delete`, `url`, types `FileObject`, `FileData`, `FilePage`: the tool's private files (capability `files`), kept by the Chest, and a 15-minute signed link to one |
24
- | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (503): what the SDK throws when the Chest does not give what a tool asks |
25
- | `@argentic/chest-sdk` | all of the above; `files` as a namespace |
34
+ | `@argentic/chest-sdk/files` | `put`, `get`, `stat`, `list`, `move`, `delete`, `url`, `uploadUrl`, types `FileObject`, `FileData`, `FilePage`: the tool's private files (capability `files`), kept by the Chest, a 15-minute signed link to one (or to its thumbnail), and uploads straight from a member's browser |
35
+ | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `RateLimited` (429), `Unavailable` (503), and for AI `AiCapReached` (402), `AiModelNotAllowed` (403), `AiRefused` (422), `AiUnavailable` (502, 503), type `AiUnavailableReason`: what the SDK throws when the Chest does not give what a tool asks |
36
+ | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `AssertionOptions`, `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeNotification`, `FakeEvent`, `FakeAi`, `FakeAiModel`, `FakeAiReply`, `FakeAiCall`: for the tool's own tests only |
37
+ | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications`, `events` and `ai` as namespaces |
26
38
 
27
39
  ```ts
28
40
  import { member } from "@argentic/chest-sdk/member";
41
+ import { chest } from "@argentic/chest-sdk/chest";
29
42
  import { databaseUrl } from "@argentic/chest-sdk/database";
30
43
  import * as files from "@argentic/chest-sdk/files";
44
+ import * as members from "@argentic/chest-sdk/members";
45
+ import * as notifications from "@argentic/chest-sdk/notifications";
46
+ import * as events from "@argentic/chest-sdk/events";
47
+ import * as ai from "@argentic/chest-sdk/ai";
31
48
  import { CapabilityNotGranted } from "@argentic/chest-sdk/errors";
32
- // or: import { member, databaseUrl, files } from "@argentic/chest-sdk";
49
+ // or: import { member, chest, databaseUrl, files, members, notifications, events, ai } from "@argentic/chest-sdk";
33
50
  ```
34
51
 
35
52
  Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
@@ -39,7 +56,7 @@ Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
39
56
  ### Next.js
40
57
 
41
58
  The SDK runs on the server only — it reads the tool's environment
42
- (`CHEST_TOKEN`, `DATABASE_URL`, `CHEST_API`) and uses Node built-ins. Import it
59
+ (`CHEST_TOKEN`, `DATABASE_URL`, `CHEST_API`, `CHEST_TIME_ZONE`…) and uses Node built-ins. Import it
43
60
  in route handlers, server components or server actions, never in a
44
61
  `"use client"` module. Webpack and Turbopack resolve the
45
62
  compiled package with no configuration (no `transpilePackages`):
@@ -59,8 +76,10 @@ export function GET(request: Request) {
59
76
  A v2 tool is an ordinary web server in a container without network, run by
60
77
  its Chest. The Chest's front is the only one to reach it; the tool reaches only
61
78
  what its launcher gives it on `127.0.0.1` (its database, the Chest's API for
62
- its files). Rights come from the Chest — the signed member, the capabilities
63
- approved for the version — and the Chest enforces them even outside the SDK:
79
+ its files, its members, its notifications and AI), and the Chest posts it the
80
+ events it receives on `/chest-events`, through the same launcher. Rights come
81
+ from the Chest — the signed member, the capabilities approved for the
82
+ version — and the Chest enforces them even outside the SDK:
64
83
  the SDK makes the calls easier, it is not a security boundary. The full
65
84
  contract (manifest `chest.json`, capabilities, build from source, catalogue)
66
85
  is described in the Chest repository, `docs/architecture.md`.
@@ -70,19 +89,38 @@ is described in the Chest repository, `docs/architecture.md`.
70
89
  A v2 tool is an ordinary web server; on its team host, the Chest relays
71
90
  `/chest` and everything below it with the `Chest-Member` header of the
72
91
  signed-in member. `member(request)` accepts a Node request (`IncomingMessage`)
73
- or a Web `Request` and returns:
92
+ or a Web `Request` and returns a `Member`, the type the `members` API
93
+ answers too:
74
94
 
75
95
  ```ts
76
- type Member = { id: string; firstName: string; lastName: string; name: string; email: string; photo?: string; role?: string; isAdmin: boolean; isBuilder: boolean };
96
+ type Member = {
97
+ id: string; // "mbr_…": the member in this Chest, the same in all its tools
98
+ firstName: string;
99
+ lastName: string;
100
+ name: string; // "Camille Martin", or the local part of the address without names
101
+ photo: string | null; // /_chest/members/{id}/photo?v=<rev> on the tool's team host
102
+ role: string | null; // one of the roles chest.json declares; null if it declares none
103
+ isAdmin: boolean; // owner or admin of the Chest
104
+ isBuilder: boolean; // builder of this tool
105
+ groups: string[]; // "grp_…": the groups that give the member this tool
106
+ language: string; // "en", "fr"…: the language the Chest speaks to this member
107
+ timeZone: string; // "America/New_York": the zone the member works in
108
+ email?: string; // only with the capability "members.email"
109
+ };
77
110
  ```
78
111
 
79
112
  or `null`: without the header, on the public host (the Chest never sends an
80
113
  assertion there and strips a client's), or for any assertion that is not
81
114
  exactly its own. Checks: compact JWS, header exactly
82
115
  `{"alg":"HS256","typ":"JWT"}`, HMAC-SHA256 signature compared in constant time
83
- under the key HMAC-SHA256("Chest-Member v1") of the text of `CHEST_TOKEN` —
84
- the Chest's derivation —, `aud` equal to `CHEST_TOOL`, `iat` and `exp` within
85
- 5 s, the shape of each claim (an unknown claim is ignored). Without
116
+ under the key HMAC-SHA256("Chest-Member v2") of the text of `CHEST_TOKEN` —
117
+ the Chest's derivation; the label changes when a claim changes meaning or
118
+ goes, so an assertion of another shape is refused rather than misread, and
119
+ stays when a claim is added —, `aud` equal to
120
+ `CHEST_TOOL`, `iat` and `exp` within 5 s, the shape of each claim (`sub` an
121
+ `mbr_` identifier, `groups` `grp_` identifiers, `language` a primary tag of
122
+ 2 or 3 lowercase letters, `time_zone` a zone of `timeZonePattern`; an
123
+ unknown claim is ignored). Without
86
124
  `CHEST_TOKEN` or `CHEST_TOOL`, nobody is a member. The function never throws
87
125
  for what a request carries.
88
126
 
@@ -92,10 +130,402 @@ const who = member(request);
92
130
  if (!who) { response.writeHead(401).end(); return; }
93
131
  ```
94
132
 
95
- `photo` is the address of the photo on the team host, `role` the role the
96
- Chest gives the member among those the manifest declares. Only the Chest's
97
- front reaches the container: the signature is a second defence; business rules
98
- (who writes what) remain the tool's.
133
+ `id` is the member's identifier in the Chest: random, never an address nor
134
+ an account of the sign-in provider, stable when the member changes their name
135
+ or address, never given to anyone else. Store it in your data; resolve names
136
+ when rendering (`members.lookup`). `photo` is served by the Chest on the team
137
+ host to members who have the tool; `role` is the one the Chest gives the
138
+ member among those the manifest declares. Only the Chest's front reaches the
139
+ container: the signature is a second defence; business rules (who writes
140
+ what) remain the tool's.
141
+
142
+ `language` is the member's own language in the Chest, else the Chest's
143
+ default: a BCP 47 primary tag among those the product speaks (`en`, `fr`
144
+ today; the SDK accepts any, so a language added to the Chest needs no new
145
+ SDK). The tool's private part (`/chest`) speaks it — to this member, on every
146
+ request — and offers no language switch of its own; only its public parts,
147
+ where nobody is signed in, keep their own switch. The members API answers
148
+ it too: a notification or an email to another member is written in *their*
149
+ language (`members.get(id).language`), not in the sender's. A tool that does not
150
+ speak that language uses its own default. `timeZone` is the zone the member
151
+ works in: the one they chose in their profile, else the one their browser
152
+ is in, else the Chest's. The members API answers it too, so a tool reminds
153
+ each member at their own hour. What is the same for every member — the
154
+ organization, the company's time zone — is not the member's: it is the
155
+ Chest's (below).
156
+
157
+ ### Times: store in UTC, decide in the Chest's zone, show in the member's
158
+
159
+ | What | Zone |
160
+ |---|---|
161
+ | An instant (created, due at, sent at) | stored as UTC: `timestamptz` in PostgreSQL, `Date` in code |
162
+ | “Today”, “this week”, a deadline's day, business hours, working days | the company's: `chest.timeZone`, `chest.today()` (the database's `current_date` is the same) |
163
+ | A time or a date shown to a member, a personal reminder's hour | theirs: `member(request).timeZone`, or `members.get(id).timeZone` outside their request |
164
+
165
+ ```ts
166
+ const who = member(request)!;
167
+ const due = await sql`select * from tasks where due_on = ${chest.today()}`; // the company's day
168
+ const shown = new Intl.DateTimeFormat(who.language, { timeZone: who.timeZone, dateStyle: "medium", timeStyle: "short" }).format(task.remindAt);
169
+ ```
170
+
171
+ ## `chest` — the Chest the tool runs in
172
+
173
+ ```ts
174
+ import { chest } from "@argentic/chest-sdk/chest";
175
+
176
+ chest.organization.name; // "Acme SAS": the organization the Chest is of, as its owner wrote it
177
+ chest.timeZone; // "Europe/Paris": an IANA zone, "UTC" until the owner sets one
178
+ chest.language; // "fr": the Chest's own language (a member's is member(request).language)
179
+ chest.today(); // "2026-09-30": the date now in the Chest's zone (or chest.today(at))
180
+ ```
181
+
182
+ The Chest gives these to every tool in its environment at each start
183
+ (`CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`), and starts every
184
+ tool again when its owner changes one in Settings → General — the tool never
185
+ asks its own admin for the company's name or zone. They are there outside a
186
+ request too: a scheduled job, a start-up task, an export. No capability is
187
+ needed: nothing here is more than what the Chest's pages show its members.
188
+
189
+ - `organization.name` is plain text of 2 to 80 characters: show it in a
190
+ header, a document or an email, never as HTML.
191
+ - `timeZone` is the day of “due today” and the hour of a reminder. The Chest
192
+ also makes it the `TimeZone` of the tool's database sessions: there,
193
+ `current_date`, `now()::date` and a `timestamptz` shown as text are in the
194
+ Chest's zone. A session may set its own (`SET TIME ZONE`), for itself.
195
+ - `language` is the language of what the tool writes for no one in
196
+ particular: a public page before the visitor chooses, an export's default.
197
+ A page of `/chest` speaks `member(request).language` instead.
198
+ - `today(at?)` is `YYYY-MM-DD` in the Chest's zone, for now or for an instant
199
+ (`Date` or milliseconds): compare it with dates your database keeps as
200
+ `date`, never with `new Date().toISOString().slice(0, 10)`, which is UTC's.
201
+
202
+ Each value is read from the environment at each access, and checked: outside a
203
+ Chest (a development server without the variables), or for a value the Chest
204
+ never gives, reading it throws a `ChestError` with the code `not_in_chest` —
205
+ a wrong zone read silently is exactly what this module exists to prevent. In
206
+ tests, `fakeChest({chest: {organization, timeZone, language}})` sets them.
207
+
208
+ ## `members` — who has the tool
209
+
210
+ A v2 tool that declares `"capabilities": ["members"]` (approved like a
211
+ permission: “Sees the name, photo, role and groups of the members who have
212
+ access to it.”) reads the members who have it, through the Chest's API
213
+ (`CHEST_API`, as for files). `"members.email"`, a permission of its own that
214
+ requires `members`, adds their addresses — to these answers and to
215
+ `member(request)`.
216
+
217
+ ```ts
218
+ import * as members from "@argentic/chest-sdk/members";
219
+ const { members: page, next } = await members.list({ q: "cam", limit: 50 }); // by name, then id
220
+ const camille = await members.get("mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya"); // Member, or null
221
+ const { members: found, former, unknown } = await members.lookup(ids); // any number of ids
222
+ const teams = await members.groups.list(); // [{id, name, members}]
223
+ ```
224
+
225
+ - **Who**: exactly the members who have the tool now — by a grant, a group,
226
+ open to all, or because they run it (owner, admins, its builders);
227
+ recomputed at every call. A member without access answers as an identifier
228
+ that does not exist (`get` → `null`, `lookup` → `unknown`).
229
+ - **`list({after, limit, q, role, group})`**: ordered by name (accents aside)
230
+ then identifier; `limit` 100 by default, 500 at most; `next` is an opaque
231
+ cursor for `after`, `null` after the last page. `q` finds the start of a
232
+ first name, a last name or a name — and of an address with `members.email`
233
+ —, whatever its case and accents; `role` and `group` keep the members of that
234
+ role or group.
235
+ - **`lookup(ids)`**: each identifier once, in the order given: `members`,
236
+ `former` (`{id, name, status: "former"}`: someone who left the Chest after
237
+ having the tool, so a record still reads “Camille Martin (former member)”;
238
+ `{id, name: null, status: "erased"}` once the owner had their data erased,
239
+ rendered “Former member”) and `unknown`. The SDK asks 200 at a time and
240
+ keeps each answer a minute in the process (5,000 at most); `forget()`
241
+ empties it, and so does every event of the members' lifecycle
242
+ (`events.handle`).
243
+ - **`groups.list()`**: the groups that give the tool, with their members'
244
+ identifiers; never the others.
245
+ - Errors: `CapabilityNotGranted` (403), `RateLimited` (429: 600 calls a minute
246
+ per instance), `Unavailable` (503), `ChestError` for the rest (`invalid_id`,
247
+ `invalid_query`).
248
+
249
+ Store identifiers, resolve names when rendering, never copy them: a copied
250
+ name or address goes stale and makes the tool a second directory to erase.
251
+
252
+ ```sql
253
+ create table tasks (
254
+ id bigint generated always as identity primary key,
255
+ title text not null,
256
+ assignee text, -- a member id, "mbr_…"
257
+ created_by text not null,
258
+ constraint assignee_is_member check (assignee ~ '^mbr_[a-z2-7]{26}$')
259
+ );
260
+ ```
261
+
262
+ ```ts
263
+ const rows = await sql`select * from tasks order by id desc limit 50`;
264
+ const people = await members.lookup(rows.flatMap(r => [r.assignee, r.created_by]).filter(Boolean));
265
+ ```
266
+
267
+ To search tasks by assignee name: `members.list({ q })` first, then
268
+ `where assignee = any($ids)`.
269
+
270
+ ## `notifications` — badges and inbox items
271
+
272
+ A v2 tool that declares `"capabilities": ["notifications"]` (approved like a
273
+ permission: “Shows counters and sends notifications, inside the Chest, to the
274
+ members who have access to it.”) tells its members what needs their
275
+ attention, inside the Chest only — no email, no push to a phone. Two
276
+ primitives:
277
+
278
+ - a **badge** is a count on the tool's tile in the Chest home and on its row
279
+ in the tools list, for one member (“99+” beyond 99): a state, set again as
280
+ often as it changes;
281
+ - a **notification** is an item in a member's inbox (the bell of the Chest):
282
+ the tool's icon and name, a title, a body, and a link that opens a page of
283
+ the tool on its team host.
284
+
285
+ ```ts
286
+ import * as notifications from "@argentic/chest-sdk/notifications";
287
+
288
+ const { delivered, skipped } = await notifications.notify([assignee], {
289
+ title: "New task: fix the door", // 1 to 80 characters
290
+ body: "Before Friday.\nKeys at the desk.", // 280 characters at most; optional
291
+ path: "/chest/tasks/42", // under /chest; /chest when not said
292
+ key: "task:42", // optional: replace, then withdraw
293
+ });
294
+ await notifications.withdraw("task:42"); // done: its items go, for everyone
295
+ await notifications.withdraw("task:42", [assignee]); // only for those
296
+ const shown = await notifications.badge.set(assignee, 3); // false: no access
297
+ const { set, skipped: noAccess } = await notifications.badge.setMany([
298
+ { memberId: assignee, count: 3 },
299
+ { memberId: reviewer, count: 0 }, // 0 clears it
300
+ ]);
301
+ ```
302
+
303
+ - **Who**: only members who have access to the tool now receive either.
304
+ `notify` answers `{delivered, skipped}`, each identifier once in the order
305
+ given; `skipped` holds identifiers the Chest does not know and members
306
+ without access (as for `members`, the two are indistinguishable). `badge.set`
307
+ answers `false` for such a member, `setMany` puts them in `skipped`.
308
+ - **`notify(memberIds, {title, body?, path?, key?})`**: 1 to 500 identifiers
309
+ (a duplicate counts once), one inbox item per recipient. `title` is 1 to 80
310
+ characters (Unicode code points), `body` 280 at most (an empty body is
311
+ none). `path` is a page of the tool's private part: `/chest`, or `/chest`
312
+ followed by `/`, `?` or `#`; printable ASCII without spaces or `\`, 512
313
+ characters at most, never `//`, no `.` or `..` segment; the Chest builds the
314
+ link on the tool's team host, so it cannot point anywhere else. `key` is
315
+ 1 to 64 of `a-z 0-9 . _ : -`.
316
+ - **Replace and withdraw.** A notification with the key of an earlier one, for
317
+ the same member, replaces it: new text, new time, first in the inbox and
318
+ unread again — never a duplicate. `withdraw(key, memberIds?)` removes the
319
+ items of that key, from every member or from those named (1 to 500), once
320
+ the thing they were about is done. It never says what existed.
321
+ - **Plain text.** The Chest removes control characters (a tab or a line break
322
+ in a title becomes a space; `body` keeps its line breaks) and the characters
323
+ that reorder text, trims both, and interprets neither Markdown nor HTML.
324
+ Every item shows the tool's icon and name beside it: a tool cannot pass for
325
+ the Chest or another tool. A title that is empty once cleaned is refused.
326
+ - **Muting is invisible.** A member may mute the tool in their profile: their
327
+ new items are then dropped, but they still count as `delivered`, and their
328
+ badges stay. The tool never learns who muted it.
329
+ - **Badges** go from 0 to 9,999, 0 clears one. `setMany` takes 1 to 500, a
330
+ member at most once.
331
+ - **Quotas**, per tool: 1,000 recipients an hour (those with access, muted or
332
+ not), 100 items per member a day (a replacement counts; one recipient at 100
333
+ refuses the whole call), 600 badge writes a minute (each badge of `setMany`
334
+ counts). Beyond, `QuotaExceeded` (429, the Chest answers `Retry-After`); a
335
+ refused call changes nothing.
336
+ - **Lifecycle**: a member who loses access loses the tool's items and badge;
337
+ removing the tool removes them all. A member's inbox keeps 500 items for 90
338
+ days.
339
+ - Errors: `CapabilityNotGranted` (403), `QuotaExceeded` (429), `Unavailable`
340
+ (503, the Chest not reached, or an answer that is not its own: the call may
341
+ or may not have happened), `ChestError` for the rest (`invalid_id`,
342
+ `invalid_title`, `invalid_text`, `invalid_path`, `invalid_key`,
343
+ `invalid_count`, `invalid_body`) — the SDK refuses these before sending
344
+ anything.
345
+
346
+ A badge suits a count that goes up and down (tasks assigned, messages
347
+ unread); a notification, an event worth a look — with a key, so that it goes
348
+ away by itself once handled.
349
+
350
+ ## `events` — the members' lifecycle
351
+
352
+ A v2 tool that holds `members` and declares `"receives": ["member.*"]` in its
353
+ `chest.json` (approved like a permission: “Is told when the members who have
354
+ access to it change or leave.”) is told, on its own `POST /chest-events`:
355
+
356
+ | Event | `data` | When |
357
+ |---|---|---|
358
+ | `member.updated` | `{id, changed: ("name" \| "photo" \| "role" \| "groups" \| "email")[]}` | Something the tool sees of a member who has it changed (`email` only with `members.email`) |
359
+ | `access.revoked` | `{id}` | The member lost access to the tool but stays in the Chest |
360
+ | `member.removed` | `{id}` | The member left the Chest: `lookup` now reads them `former` |
361
+ | `member.erased` | `{id, erasure, deadline}` | The owner asked for this person's data to be erased: delete or anonymise what the tool keeps of them before `deadline` (30 days), then `acknowledgeErasure(erasure)` |
362
+
363
+ A member who gets the tool is no event: the next `list` has them.
364
+
365
+ ```jsonc
366
+ // chest.json
367
+ { "capabilities": ["members"], "receives": ["member.*"] }
368
+ ```
369
+
370
+ ```ts
371
+ // app/chest-events/route.ts — at the root, outside /chest: the Chest calls it
372
+ // through the tool's launcher, never from a browser (its front answers 404 there).
373
+ import * as events from "@argentic/chest-sdk/events";
374
+
375
+ const seen = {
376
+ has: async (id: string) => (await sql`select 1 from chest_events where id = ${id}`).length > 0,
377
+ add: async (id: string) => { await sql`insert into chest_events (id) values (${id}) on conflict do nothing`; },
378
+ };
379
+
380
+ export async function POST(request: Request) {
381
+ return new Response(null, { status: await events.handle(request, {
382
+ "member.updated": e => refreshCache(e.data.id, e.data.changed),
383
+ "access.revoked": e => sql`update tasks set assignee = null where assignee = ${e.data.id}`,
384
+ "member.erased": async e => {
385
+ await sql`update tasks set created_by = 'erased' where created_by = ${e.data.id}`;
386
+ await events.acknowledgeErasure(e.data.erasure);
387
+ },
388
+ }, { seen }) });
389
+ }
390
+ ```
391
+
392
+ - **Delivery**: at least once, in no guaranteed order. An event is an
393
+ envelope `{id: "evt_…", type, occurredAt, data}`, signed for this tool
394
+ (`Chest-Event` header, HS256 under a key derived from `CHEST_TOKEN` with
395
+ the label `Chest-Event v1`, naming the event and the SHA-256 of the body,
396
+ 60 seconds). Any answer but a 2xx is delivered again, the same event with
397
+ the same id, after 5 s, 15 s, 30 s, 1 min, 2 min, 5 min, 10 min, 30 min, then
398
+ every hour, for 72 hours — a restart of the node included. Given up, the
399
+ tool is marked “out of sync” on its page until its next start.
400
+ - **`members.list` is the truth.** Reconcile by listing at start (and so after
401
+ being out of sync): events keep a tool current between starts, they do not
402
+ replace reading who has it.
403
+ - **`handle(request, handlers, {seen?})`** answers the status to give the
404
+ Chest: 401 for what is not a delivery of the Chest for this tool, 204 for an
405
+ event handled, one already in `seen`, a type without a handler, or one of a
406
+ later Chest (signed, ignored). It reads the body (64 KiB at most): mount it
407
+ before any body parser. A handler that throws leaves the event unseen and
408
+ `handle` throws: answer 500, it comes again. `seen` is the store of the
409
+ handled ids — `memorySeen()` (the default: 10,000 ids in the process, lost
410
+ at a restart) or a table of the tool's own, as above. Make handlers
411
+ idempotent anyway: an event handled but not yet added to `seen` when the
412
+ tool stops comes again.
413
+ - **`verify(request)`** is the event of a delivery, typed, or `null`; for a
414
+ tool that routes events itself.
415
+ - **`acknowledgeErasure(erasure)`**: `POST /erasures/{erasure}/done` on the
416
+ Chest's API; the owner then sees the tool's part done (“Erased on 3 Oct.”),
417
+ “Overdue” past the deadline otherwise. Again is harmless. Errors:
418
+ `ChestError` `erasure_not_found` (404: an erasure this tool was not told of)
419
+ or `invalid_id` (400), `CapabilityNotGranted` (403), `Unavailable`.
420
+
421
+ ## `ai` — AI models through the Chest
422
+
423
+ A v2 tool that declares the `ai` capability calls AI models through its
424
+ Chest. The Chest's owner connects OpenRouter with the company's own key;
425
+ the tool calls models by four **aliases**: `default`, `fast`, `smart`,
426
+ `embedding`, each led by the Chest to a model it chose (the owner may choose
427
+ another for `default`). The tool names an alias, never a provider's model:
428
+ the model changes for the whole Chest without touching code. The tool never
429
+ holds a key; the Chest meters every call against the tool's monthly cap.
430
+
431
+ ```jsonc
432
+ // chest.json
433
+ {
434
+ "capabilities": ["ai"],
435
+ "ai": { "monthly": 20, "models": ["default", "embedding"], "purpose": "Summarises support tickets" }
436
+ }
437
+ ```
438
+
439
+ | Key | Default | |
440
+ |---|---|---|
441
+ | `monthly` | 5 | Whole euros a month, 1 to 1,000: what the tool asks; the owner's cap replaces it and may be changed at any time |
442
+ | `models` | `["default"]` | 1 to 4 of `default`, `fast`, `smart`, `embedding`: the only aliases the tool may call |
443
+ | `purpose` | required | 1 to 120 characters, shown at approval: “Uses AI models through the Chest, up to €20 a month” |
444
+
445
+ ```ts
446
+ import * as ai from "@argentic/chest-sdk/ai";
447
+
448
+ const r = await ai.chat({
449
+ model: "default",
450
+ messages: [{ role: "system", content: "Summarise in two sentences." }, { role: "user", content: ticket.text }],
451
+ maxTokens: 300, // 1 to 128,000; 4,096 when not said
452
+ member: who.id, // optional: attribution in the Chest's usage log
453
+ });
454
+ r.text; // "" when the model only called tools
455
+ r.usage; // { input, output, cached, cost } — cost in estimated euros
456
+
457
+ // Streamed: pieces as they come; breaking out of the loop ends the call.
458
+ for await (const chunk of ai.chat({ model: "fast", messages, stream: true, signal })) {
459
+ write(chunk.text); // chunk.toolCalls, chunk.finishReason, then chunk.usage last
460
+ }
461
+
462
+ // Tools: the model asks, the tool runs them and answers.
463
+ const step = await ai.chat({ model: "smart", messages, tools: [{ type: "function", function: { name: "lookup", parameters: schema } }] });
464
+ messages.push(step.message);
465
+ for (const call of step.toolCalls) messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(await run(call.name, JSON.parse(call.arguments))) });
466
+
467
+ const { embeddings } = await ai.embed({ model: "embedding", input: ["first text", "second text"] }); // 1 to 256 texts
468
+ const mapped = await ai.models(); // [{ alias, model, provider, input, output }] — USD per million tokens
469
+ const month = await ai.usage(); // { month: "2026-09", spent, cap, resetsAt }
470
+ ```
471
+
472
+ - **`chat(options)`** takes the OpenAI Chat Completions request in camelCase:
473
+ `model`, `messages`, `maxTokens`, `temperature`, `topP`, `stop`, `tools`,
474
+ `toolChoice`, `responseFormat`, `parallelToolCalls`, `seed`,
475
+ `reasoningEffort`, plus `member`, `stream` and `signal`. Messages, tools and
476
+ response formats keep the OpenAI shape (images in `content` parts too); the
477
+ Chest translates them for each provider and never runs a tool. Without
478
+ `stream` it returns `Promise<ChatResult>` `{text, message, toolCalls,
479
+ finishReason, model, usage}` — `message` is the assistant's message to add
480
+ to the conversation, `toolCalls` are `{id, name, arguments}` with the
481
+ arguments as JSON text, `model` is the provider's model. With
482
+ `stream: true` it returns an `AsyncIterable<ChatChunk>` `{text, toolCalls?,
483
+ finishReason?, usage?}`: tool calls come in pieces (`{index, id?, name?,
484
+ arguments?}`: join the `arguments` of the same `index`), the usage in the
485
+ last chunk.
486
+ - **Bounds**: a request body of 10 MiB (images included), 16 MiB of answer, a
487
+ call ends after 10 minutes (streamed or not); 60 requests a minute and 8
488
+ streams at once per tool (`RateLimited`). An aborted `signal` throws its
489
+ reason.
490
+ - **The cap never overshoots**: before a call the Chest reserves its worst
491
+ case (input and `maxTokens`) against the tool's cap and the Chest's; a call
492
+ that does not fit is refused before anything is spent. Keep `maxTokens` to
493
+ what the answer needs.
494
+ - **`embed({model, input, dimensions?, member?})`** gives one vector per text,
495
+ in the order given, and the input tokens and cost.
496
+ - **`models()`** gives the aliases the tool declared that the owner mapped,
497
+ with their model, provider and prices; **`usage()`** the tool's month:
498
+ estimated euros spent, the cap in force, and when the month resets.
499
+
500
+ **AI can stop at any time** — the month's budget spent, no connector, the
501
+ provider down. Keep the tool usable without it:
502
+
503
+ ```ts
504
+ import { AiCapReached, AiUnavailable } from "@argentic/chest-sdk/errors";
505
+
506
+ let summary: string | null = null;
507
+ try {
508
+ summary = (await ai.chat({ model: "default", messages, maxTokens: 300 })).text;
509
+ } catch (error) {
510
+ if (!(error instanceof AiCapReached || error instanceof AiUnavailable)) throw error;
511
+ // summary stays null: show "AI features are paused" and keep the page working
512
+ }
513
+ ```
514
+
515
+ | Error | Code, status | When |
516
+ |---|---|---|
517
+ | `AiCapReached` | `cap_reached` 402 | The tool's (`scope: "tool"`) or the Chest's (`scope: "chest"`) monthly cap is spent, until `resetsAt` |
518
+ | `AiUnavailable` | `no_connector` 503, `provider_key_invalid` 502, `provider_unavailable` 503 | No connector behind the alias, the provider refused the connector's key, or failed (`reason`) |
519
+ | `AiModelNotAllowed` | `model_not_allowed` 403 | An alias the tool did not declare in `models` (the SDK refuses any other name before sending) |
520
+ | `CapabilityNotGranted` | `capability_not_granted` 403 | The version does not hold `ai`, or it was not approved |
521
+ | `AiRefused` | `content_refused` 422 | The provider's moderation refused the content |
522
+ | `RateLimited` | `rate_limited` 429 | 60 requests a minute or 8 streams at once |
523
+ | `TooLarge` | `too_large` 413 | A body beyond 10 MiB, or a context beyond the model's |
524
+ | `ChestError` | `invalid_body`, `invalid_request` 400 | A malformed request (the SDK refuses most before sending), or parameters the provider rejected (its message in the error's) |
525
+ | `Unavailable` | `unavailable` 503 | The Chest not reached, or an answer that is not its own; in a stream, the stream cut |
526
+
527
+ An error in the middle of a stream is thrown where it comes, after the chunks
528
+ before it.
99
529
 
100
530
  ## `databaseUrl()` — database of a server tool
101
531
 
@@ -137,32 +567,135 @@ previous version working — going back to the previous version undoes nothing.
137
567
 
138
568
  A v2 tool that declares `"capabilities": ["files"]` (approved like a permission)
139
569
  keeps private files **through its Chest**, never on its disk (the container's
140
- root is read-only): 1 GiB and 10,000 objects per tool, 32 MiB per object. The
141
- launcher gives the tool `CHEST_API=http://127.0.0.1:<port>` — its own port,
142
- relayed to the Chest; the container has no network — and the instance is the
143
- identity: the tool reaches its own files only.
570
+ root is read-only). The launcher gives the tool
571
+ `CHEST_API=http://127.0.0.1:<port>` — its own port, relayed to the Chest; the
572
+ container has no network — and the instance is the identity: the tool reaches
573
+ its own files only.
144
574
 
145
575
  ```ts
146
576
  import * as files from "@argentic/chest-sdk/files";
147
577
  await files.put("photos/cat.png", bytes, "image/png"); // Uint8Array or text
148
578
  const file = await files.get("photos/cat.png"); // {data, type, size} or null
579
+ const info = await files.stat("photos/cat.png"); // {name, type, size, updated, width?, height?} or null
149
580
  const { files: page, next } = await files.list({ prefix: "photos/" }); // 1000 per page
150
- await files.delete("photos/cat.png"); // true, or false if it did not exist
151
- const { url, expiresIn } = await files.url("photos/cat.png");
581
+ await files.move("photos/cat.png", "archive/cat.png"); // atomic; replaces archive/cat.png
582
+ await files.delete("archive/cat.png"); // true, or false if it did not exist
583
+ const { url, expiresIn } = await files.url("photos/dog.png", { thumbnail: 256 });
152
584
  ```
153
585
 
154
586
  A name: up to 8 segments of 1 to 100 letters, digits, `.`, `_` or `-`,
155
587
  separated by `/`, none starting with `.` or `-`; refused before anything is
156
- sent otherwise (`ChestError`, `invalid_name`). `url` signs a link to the file
157
- as it is, on the tool's **team host** (`/_chest/files/…`): whoever has it opens
158
- it without signing in for 15 minutes, or until the file changes or goes; the
159
- Chest serves it in a sandbox, displayed for an image, a PDF or plain text,
160
- downloaded otherwise. Give it to a member's browser, never to a public page.
588
+ sent otherwise (`ChestError`, `invalid_name`).
589
+
590
+ ### Limits
591
+
592
+ Per tool: 1 GiB, 10,000 objects and 32 MiB per object, unless its manifest
593
+ asks otherwise — approved by the owner like any permission, and a later
594
+ version that asks more is approved again:
595
+
596
+ ```jsonc
597
+ // chest.json
598
+ { "capabilities": ["files"], "files": { "quota": "5 GiB", "maxObject": "100 MiB" } }
599
+ ```
600
+
601
+ `quota` goes from 100 MiB to 100 GiB (10 GiB and more allow 100,000 objects),
602
+ `maxObject` from 1 to 512 MiB; the owner or an admin may also set the quota
603
+ by hand. The SDK refuses beyond 512 MiB before sending anything; the Chest
604
+ holds the tool to its own bounds (`TooLarge`, `QuotaExceeded`). `put` and
605
+ `get` carry the bytes through the tool's server: for large files, let the
606
+ browser upload them itself.
607
+
608
+ ### Uploads from a member's browser
609
+
610
+ The bytes go from the browser to the Chest directly, never through the tool.
611
+ The tool authorises one upload, in a `/chest` route, once `member()` said who
612
+ asks:
613
+
614
+ ```ts
615
+ // Server side: app/chest/api/invoices/upload/route.ts
616
+ const up = await files.uploadUrl("invoices/2026/0042.pdf", {
617
+ maxSize: 10 << 20, // bytes; the tool's largest object when not said
618
+ types: ["application/pdf"], // up to 8, "image/*" for a family; any when not said
619
+ expiresIn: 300, // 1 to 900 seconds; 900 when not said
620
+ });
621
+ // → { url, method: "PUT", expiresIn }: hand it to the member's browser
622
+ ```
623
+
624
+ ```ts
625
+ // Browser side, on a page under /chest: the member's session goes with it
626
+ const response = await fetch(up.url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
627
+ // 201 {name, type, size}; 403 invalid_token (used, expired), 415 type_refused,
628
+ // 400 type_mismatch, 413 too_large, 429 quota_exceeded, 401 without a session
629
+ ```
630
+
631
+ ```ts
632
+ // Server side, when the browser says it is done
633
+ const info = await files.stat("invoices/2026/0042.pdf"); // null if nothing came
634
+ ```
635
+
636
+ `url` is `https://<tool's team host>/_chest/files/upload/<token>`: the same
637
+ origin as the tool's `/chest` pages, so no CORS. The token is signed by the
638
+ Chest and binds the name, the size, the types and the expiry; it serves once.
639
+ A name ending in `/` is a folder: the Chest then names the object (20 hex
640
+ characters and an extension from its type) and answers its name. The Chest
641
+ checks the declared size before reading, drops the body at the first byte too
642
+ many, and for images, PDFs and archives checks that the first bytes are of the
643
+ type sent; nothing of a refused upload remains. There is no antivirus scan.
644
+ `uploadUrl` answers `Unavailable` while the Chest does not know the tool's
645
+ team host yet.
646
+
647
+ ### Links and thumbnails
648
+
649
+ `url(name, {thumbnail?, download?})` signs a link to the file as it is, on the
650
+ tool's **team host** (`/_chest/files/…`): whoever has it opens it without
651
+ signing in for 15 minutes, or until the file changes or goes; the Chest serves
652
+ it in a sandbox, displayed for an image, a PDF or plain text, downloaded
653
+ otherwise, or always downloaded with `download: true`. `thumbnail: 256` or
654
+ `1024` links to the image reduced to that many pixels (JPEG, PNG, GIF — its
655
+ first frame — and WebP up to 40 megapixels; `ChestError` `no_thumbnail`
656
+ otherwise); thumbnails are made once, not counted in the quota. `stat` gives
657
+ `width` and `height` for these images. Give a link to a member's browser,
658
+ never to a public page.
659
+
161
660
  Errors: `CapabilityNotGranted` (a version without the capability, or no
162
661
  `CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (the
163
662
  Chest not reached, or an answer that is not its own: a write may or may not
164
- have happened), `ChestError` for the rest (`invalid_type`, `not_found` for
165
- `url`…). Removing the tool removes its files; a new version keeps them.
663
+ have happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
664
+ `not_found` for `url` and `move`…). Removing the tool removes its files; a new
665
+ version keeps them.
666
+
667
+ ## `testing` — a tool's own tests
668
+
669
+ `@argentic/chest-sdk/testing` is for tests, never imported by production code.
670
+
671
+ ```ts
672
+ import { fakeChest, signAssertion, withMember } from "@argentic/chest-sdk/testing";
673
+
674
+ const camille = { id: "mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya", firstName: "Camille", lastName: "Martin", name: "Camille Martin", photo: null, role: "editor", isAdmin: false, isBuilder: false, groups: [], language: "fr", timeZone: "Europe/Paris" };
675
+ const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications", "ai"], ai: { reply: () => "Summary." } });
676
+ const response = await handler(withMember(new Request("http://tool.test/chest/tasks"), camille));
677
+ assert.equal(await chest.emit({ type: "member.erased", data: { id: camille.id, erasure: "era_k2qhx4mzc7v3b6nfp5r2t7w4ya", deadline: "2026-10-28T10:00:00Z" } }, request => handler(request)), 204);
678
+ assert.deepEqual(chest.acknowledged, ["era_k2qhx4mzc7v3b6nfp5r2t7w4ya"]);
679
+ assert.deepEqual((await members.list()).members.map(m => m.id), [camille.id]);
680
+ assert.ok(chest.files.has("reports/2026.pdf"));
681
+ assert.deepEqual(chest.notifications, [{ member: camille.id, title: "New task", path: "/chest/tasks/42", key: "task:42" }]);
682
+ assert.equal(chest.badges.get(camille.id), 1);
683
+ assert.equal(chest.ai[0]?.path, "/ai/chat");
684
+ await chest.close();
685
+ ```
686
+
687
+ | Function | Gives |
688
+ |---|---|
689
+ | `signAssertion(member, {token?, tool?, now?})` | A `Chest-Member` header value signed like the Chest's for that `Member` (the token and tool of the environment by default), signed as given, so a language or a zone the Chest never sends makes `member()` refuse it |
690
+ | `withMember(request, member, options?)` | The request carrying that assertion (the options of `signAssertion`): a new Web `Request`, or the same Node request |
691
+ | `fakeChest({members?, former?, groups?, capabilities?, receives?, files?, ai?, chest?})` | An HTTP server on `127.0.0.1` that sets `CHEST_API`, `CHEST_TOKEN`, `CHEST_TOOL` (`tool` unless set), the Chest's `CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE` (`chest: {organization, timeZone, language}`: `"Test organization"`, `"UTC"`, `"en"` by default) and answers members, groups, files, badges, notifications, AI and erasure acknowledgments with a Chest's bounds, quotas and errors; a capability left out answers 403 (`members`, `files`, `notifications` and `ai` by default; `members.email` adds the addresses; `receives` is `["member.*"]` by default, `[]` refuses acknowledgments). A former member `{id, name?, erased?}` looks up as `former`, or `erased` |
692
+ | `chest.emit(event, to)` | Delivers an event (`{type, data, id?, occurredAt?}`: a new id and now by default; name an id to deliver the same event twice) signed as the Chest signs it, to `to` — the tool's address (`POST <to>/chest-events`) or a function of a Web `Request` — and says the status it answered. A `member.erased` makes its erasure one the tool may acknowledge |
693
+ | `ai: {models?, reply?, cap?, unavailable?}` | The fake Chest's AI, deterministic and without any provider. `models`: the aliases the tool declared, `{alias, model, provider?, input?, output?}` (all four by default, `fake-default`…`fake-embedding`, provider `openrouter`, 1 and 2 USD per million tokens); another alias answers `model_not_allowed`. `reply(request)`: what a chat answers, given the wire request — a string, or `{text?, toolCalls?: {name, arguments, id?}[]}` (by default the last user message, echoed); streamed, it comes word by word, each tool call's arguments in two pieces, then the finish reason and the usage. Embeddings are unit vectors from a hash of each text (8 dimensions unless `dimensions`). Tokens count one per 4 characters; once the spending reaches `cap` (euros, 5 by default; 0 refuses at once) a call answers `cap_reached`. `unavailable` (`no_connector`, `provider_key_invalid`, `provider_unavailable`) makes chat and embeddings answer it. 60 requests a minute |
694
+ | `chest.ai` | The tool's calls to AI, `{path, body}` in order (`body` null for a `GET`) |
695
+ | `chest.acknowledged` | The erasures the tool acknowledged, each once |
696
+ | `chest.members`, `chest.groups`, `chest.files` | What the fake Chest holds, to change or assert on; its `members` are those who have the tool |
697
+ | `chest.notifications`, `chest.badges` | What the tool sent: the items kept, `{member, title, body?, path, key?}` cleaned as the Chest cleans them, in the order sent (a replaced item removed, the new one last; `withdraw` removes), and each member's badge (`Map` member → count; 0 removes it) |
698
+ | `chest.close()` | Stops it and restores the environment |
166
699
 
167
700
  ## Version
168
701
 
@@ -191,9 +724,12 @@ npm run check:package # npm pack, install into a temp project, import every sub
191
724
  ```
192
725
 
193
726
  `client/src` holds the modules, `client/index.ts` the package root,
194
- `client/test` the tests. `npm run build` compiles `client/index.ts` and the
195
- four published modules (`errors`, `member`, `database`, `files`; TypeScript
196
- strict, ES2022, NodeNext) into `dist/`: ESM `.js`, `.d.ts` and their maps.
727
+ `client/test` the tests. `npm run build` compiles `client/index.ts`, the
728
+ nine published modules (`errors`, `member`, `members`, `database`, `files`,
729
+ `notifications`, `events`, `ai`, `testing`) and the one they share (`api`, the Chest's API) —
730
+ TypeScript strict, ES2022, NodeNext — into `dist/`: ESM `.js`, `.d.ts` and
731
+ their maps. `member.ts` imports nothing but `node:*`, so that a tool may copy
732
+ it alone.
197
733
  The package stays dependency-free (`node:*` only) and reaches nothing but the
198
734
  Chest's API on `127.0.0.1`. `AGENTS.md` is a usage guide for AI agents
199
735
  building a tool with this package.