@argentic/chest-sdk 0.2.0 → 0.4.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 (62) hide show
  1. package/README.md +406 -51
  2. package/client/index.ts +8 -5
  3. package/client/src/ai.ts +374 -0
  4. package/client/src/api.ts +25 -3
  5. package/client/src/chest.ts +104 -0
  6. package/client/src/errors.ts +44 -0
  7. package/client/src/events.ts +12 -104
  8. package/client/src/files.ts +9 -9
  9. package/client/src/member.ts +26 -5
  10. package/client/src/members.ts +21 -16
  11. package/client/src/schedules.ts +87 -0
  12. package/client/src/signed.ts +166 -0
  13. package/client/src/testing.ts +290 -65
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/src/ai.d.ts +131 -0
  19. package/dist/src/ai.d.ts.map +1 -0
  20. package/dist/src/ai.js +290 -0
  21. package/dist/src/ai.js.map +1 -0
  22. package/dist/src/api.d.ts +4 -0
  23. package/dist/src/api.d.ts.map +1 -1
  24. package/dist/src/api.js +25 -2
  25. package/dist/src/api.js.map +1 -1
  26. package/dist/src/chest.d.ts +15 -0
  27. package/dist/src/chest.d.ts.map +1 -0
  28. package/dist/src/chest.js +61 -0
  29. package/dist/src/chest.js.map +1 -0
  30. package/dist/src/errors.d.ts +16 -0
  31. package/dist/src/errors.d.ts.map +1 -1
  32. package/dist/src/errors.js +36 -0
  33. package/dist/src/errors.js.map +1 -1
  34. package/dist/src/events.d.ts +3 -6
  35. package/dist/src/events.d.ts.map +1 -1
  36. package/dist/src/events.js +8 -102
  37. package/dist/src/events.js.map +1 -1
  38. package/dist/src/files.d.ts +1 -0
  39. package/dist/src/files.d.ts.map +1 -1
  40. package/dist/src/files.js +6 -7
  41. package/dist/src/files.js.map +1 -1
  42. package/dist/src/member.d.ts +4 -0
  43. package/dist/src/member.d.ts.map +1 -1
  44. package/dist/src/member.js +17 -5
  45. package/dist/src/member.js.map +1 -1
  46. package/dist/src/members.d.ts +1 -1
  47. package/dist/src/members.d.ts.map +1 -1
  48. package/dist/src/members.js +9 -8
  49. package/dist/src/members.js.map +1 -1
  50. package/dist/src/schedules.d.ts +15 -0
  51. package/dist/src/schedules.d.ts.map +1 -0
  52. package/dist/src/schedules.js +45 -0
  53. package/dist/src/schedules.js.map +1 -0
  54. package/dist/src/signed.d.ts +29 -0
  55. package/dist/src/signed.d.ts.map +1 -0
  56. package/dist/src/signed.js +139 -0
  57. package/dist/src/signed.js.map +1 -0
  58. package/dist/src/testing.d.ts +53 -12
  59. package/dist/src/testing.d.ts.map +1 -1
  60. package/dist/src/testing.js +265 -60
  61. package/dist/src/testing.js.map +1 -1
  62. package/package.json +27 -4
package/README.md CHANGED
@@ -1,11 +1,16 @@
1
1
  # Chest SDK
2
2
 
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 other members
5
- who have the tool, the address of the tool's own database, its private
6
- files, the badges and notifications it shows members inside the Chest, the
7
- events of its members' lifecycle — and, for the tool's tests, a fake Chest. The SDK has no dependency: it
8
- only imports `node:*`.
3
+ `@argentic/chest-sdk` is what a server tool (tool contract 0.4) embeds to talk
4
+ with its Chest: the member the Chest asserts on a request, the Chest itself
5
+ (its organization, time zone, language and currency, and where the tool is
6
+ reached), the other members who have the tool, the address of the tool's own
7
+ database, its private files, the badges and notifications it shows members
8
+ inside the Chest, the events of its members' lifecycle, AI models through the
9
+ Chest — and, for the tool's tests, a fake Chest. It also publishes the tool
10
+ contract ([`contract/`](contract/README.md): what `chest.json` may say, what
11
+ the Chest builds, what migrations may do, the policies it adds) and `chest
12
+ check`, the Chest's own validator. The SDK has no dependency: it only
13
+ imports `node:*`.
9
14
 
10
15
  ```sh
11
16
  npm install @argentic/chest-sdk
@@ -16,33 +21,42 @@ Node 22 or later. ESM only, compiled JavaScript with its type declarations.
16
21
  ## Imports
17
22
 
18
23
  Each module is its own subpath and pulls in nothing else; the root gives them
19
- all, with the files, members, notifications and events APIs as the
20
- namespaces `files`, `members`, `notifications` and `events` (the testing
21
- module is not in the root).
24
+ all, with the files, members, notifications, events, schedules and ai APIs
25
+ as the namespaces `files`, `members`, `notifications`, `events`, `schedules`
26
+ and `ai` (the testing module is not in the root).
22
27
 
23
28
  | Import | Gives |
24
29
  |---|---|
25
- | `@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. `memberIdPattern`, `groupIdPattern`: the grammars of the identifiers (`mbr_…`, `grp_…`) |
30
+ | `@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 |
31
+ | `@argentic/chest-sdk/chest` | `chest`, type `Chest`: the Chest the tool runs in — `chest.organization.name`, `chest.timeZone`, `chest.language`, `chest.currency`, `chest.today()` — and where the tool is reached — `chest.tool.teamUrl`, `chest.tool.publicUrl` —, the same for every member, on a request or outside one |
26
32
  | `@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`) |
27
33
  | `@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`) |
28
34
  | `@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 |
35
+ | `@argentic/chest-sdk/schedules` | `handle`, `verify`, types `Run`, `Handlers`, `Seen`: the runs of the tool's schedules (`"schedules"` in `chest.json`) the Chest posts to its `/chest-schedules` at their times, verified, deduplicated by id |
36
+ | `@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`) |
29
37
  | `@argentic/chest-sdk/database` | `databaseUrl()`: the address of the tool's own PostgreSQL database (capability `database`) |
30
38
  | `@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 |
31
- | `@argentic/chest-sdk/errors` | `ChestError` (`code`, `status`), `CapabilityNotGranted` (403), `TooLarge` (413), `QuotaExceeded` (429), `RateLimited` (429), `Unavailable` (503): what the SDK throws when the Chest does not give what a tool asks |
32
- | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeNotification`, `FakeEvent`: for the tool's own tests only |
33
- | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications` and `events` as namespaces |
39
+ | `@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 |
40
+ | `@argentic/chest-sdk/testing` | `signAssertion`, `withMember`, `fakeChest`, types `AssertionOptions`, `FakeChest`, `FakeChestOptions`, `FakeGroup`, `FakeFile`, `FakeFormer`, `FakeNotification`, `FakeEvent`, `FakeRun`, `FakeAi`, `FakeAiModel`, `FakeAiReply`, `FakeAiCall`: for the tool's own tests only |
41
+ | `@argentic/chest-sdk` | all of the above but `testing`; `files`, `members`, `notifications`, `events`, `schedules` and `ai` as namespaces |
34
42
 
35
43
  ```ts
36
44
  import { member } from "@argentic/chest-sdk/member";
45
+ import { chest } from "@argentic/chest-sdk/chest";
37
46
  import { databaseUrl } from "@argentic/chest-sdk/database";
38
47
  import * as files from "@argentic/chest-sdk/files";
39
48
  import * as members from "@argentic/chest-sdk/members";
40
49
  import * as notifications from "@argentic/chest-sdk/notifications";
41
50
  import * as events from "@argentic/chest-sdk/events";
51
+ import * as ai from "@argentic/chest-sdk/ai";
42
52
  import { CapabilityNotGranted } from "@argentic/chest-sdk/errors";
43
- // or: import { member, databaseUrl, files, members, notifications, events } from "@argentic/chest-sdk";
53
+ // or: import { member, chest, databaseUrl, files, members, notifications, events, ai } from "@argentic/chest-sdk";
44
54
  ```
45
55
 
56
+ `chest check`, the Chest's validator, is a separate development package,
57
+ `@argentic/chest-check`, not published yet (see [Check a tool](#check-a-tool--chest-check)):
58
+ this one stays a small runtime client.
59
+
46
60
  Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
47
61
  `@types/node`, as any Node project does. Both `moduleResolution` `bundler` and
48
62
  `nodenext` work.
@@ -50,7 +64,7 @@ Types refer to `node:http` (`IncomingMessage`): a TypeScript project needs
50
64
  ### Next.js
51
65
 
52
66
  The SDK runs on the server only — it reads the tool's environment
53
- (`CHEST_TOKEN`, `DATABASE_URL`, `CHEST_API`) and uses Node built-ins. Import it
67
+ (`CHEST_TOKEN`, `DATABASE_URL`, `CHEST_API`, `CHEST_TIME_ZONE`…) and uses Node built-ins. Import it
54
68
  in route handlers, server components or server actions, never in a
55
69
  `"use client"` module. Webpack and Turbopack resolve the
56
70
  compiled package with no configuration (no `transpilePackages`):
@@ -67,23 +81,57 @@ export function GET(request: Request) {
67
81
 
68
82
  ## The contract, in short
69
83
 
70
- A v2 tool is an ordinary web server in a container without network, run by
84
+ A tool is an ordinary web server in a container without network, run by
71
85
  its Chest. The Chest's front is the only one to reach it; the tool reaches only
72
86
  what its launcher gives it on `127.0.0.1` (its database, the Chest's API for
73
- its files, its members and its notifications), and the Chest posts it the
74
- events it receives on `/chest-events`, through the same launcher. Rights come
87
+ its files, its members, its notifications and AI), and the Chest posts it the
88
+ events it receives on `/chest-events` and the runs of its schedules on
89
+ `/chest-schedules`, through the same launcher. Rights come
75
90
  from the Chest — the signed member, the capabilities approved for the
76
91
  version — and the Chest enforces them even outside the SDK:
77
- the SDK makes the calls easier, it is not a security boundary. The full
78
- contract (manifest `chest.json`, capabilities, build from source, catalogue)
79
- is described in the Chest repository, `docs/architecture.md`.
92
+ the SDK makes the calls easier, it is not a security boundary. The contract
93
+ itself — every key of `chest.json` and its bounds, what the Chest builds,
94
+ what migrations may create, the Content-Security-Policy it adds, Next.js on
95
+ a Chest — is [`contract/README.md`](contract/README.md), rendered from the
96
+ Chest's own code.
97
+
98
+ ## Check a tool — `chest check`
99
+
100
+ ```sh
101
+ # once, in a clone of chest-by-argentic/Chest-SDK (not on npm yet)
102
+ npm ci # builds check/ too
103
+ npx chest check /path/to/the/tool # --json for agents and CI
104
+ # or, in the tool's repository, a local devDependency
105
+ npm install --save-dev /path/to/Chest-SDK/check
106
+ npx chest check
107
+ ```
108
+
109
+ `@argentic/chest-check` is `check/` of this repository, not published on npm
110
+ yet: it runs from a clone.
111
+
112
+ The Chest's own validator — the code a Chest runs on every repository it
113
+ builds, compiled to WebAssembly (1.6 MB, in its own package, `check/` of
114
+ this repository, so that a tool's runtime dependencies stay small) — judges the repository as
115
+ the Chest would receive it: the files Git tracks or would add, as they are
116
+ now, committed or not. It says `OK` with the tool's name, roles, what it
117
+ asks and its migrations, or `Refused` with the Chest's reason (`manifest`,
118
+ `migrations`, `no_lock`, `newer_chest`…) and the rule broken; exit status 0,
119
+ 1, or 2 when it could not run (not a Git repository). It reads nothing but
120
+ the archive it is given, and needs no network and no Chest. Details:
121
+ [`contract/README.md`](contract/README.md#check-a-repository).
122
+
123
+ `chest.json` names the version of the contract the tool is written for,
124
+ `"chest": "0.4"` — the MAJOR.MINOR of this SDK. A Chest older than that
125
+ refuses the tool with “This tool needs a newer version of your Chest”;
126
+ up to its own version, a key it does not know is refused, never ignored.
80
127
 
81
- ## `member(request)` — server tool (contract v2)
128
+ ## `member(request)` — the member of a request
82
129
 
83
- A v2 tool is an ordinary web server; on its team host, the Chest relays
130
+ A tool is an ordinary web server; on its team host, the Chest relays
84
131
  `/chest` and everything below it with the `Chest-Member` header of the
85
132
  signed-in member. `member(request)` accepts a Node request (`IncomingMessage`)
86
- or a Web `Request` and returns:
133
+ or a Web `Request` and returns a `Member`, the type the `members` API
134
+ answers too:
87
135
 
88
136
  ```ts
89
137
  type Member = {
@@ -96,6 +144,8 @@ type Member = {
96
144
  isAdmin: boolean; // owner or admin of the Chest
97
145
  isBuilder: boolean; // builder of this tool
98
146
  groups: string[]; // "grp_…": the groups that give the member this tool
147
+ language: string; // "en", "fr"…: the language the Chest speaks to this member
148
+ timeZone: string; // "America/New_York": the zone the member works in
99
149
  email?: string; // only with the capability "members.email"
100
150
  };
101
151
  ```
@@ -105,10 +155,13 @@ assertion there and strips a client's), or for any assertion that is not
105
155
  exactly its own. Checks: compact JWS, header exactly
106
156
  `{"alg":"HS256","typ":"JWT"}`, HMAC-SHA256 signature compared in constant time
107
157
  under the key HMAC-SHA256("Chest-Member v2") of the text of `CHEST_TOKEN` —
108
- the Chest's derivation; the label changes with the shape of the claims, so an
109
- assertion of another shape is refused rather than misread —, `aud` equal to
158
+ the Chest's derivation; the label changes when a claim changes meaning or
159
+ goes, so an assertion of another shape is refused rather than misread, and
160
+ stays when a claim is added —, `aud` equal to
110
161
  `CHEST_TOOL`, `iat` and `exp` within 5 s, the shape of each claim (`sub` an
111
- `mbr_` identifier, `groups` `grp_` identifiers; an unknown claim is ignored). Without
162
+ `mbr_` identifier, `groups` `grp_` identifiers, `language` a primary tag of
163
+ 2 or 3 lowercase letters, `time_zone` a zone of `timeZonePattern`; an
164
+ unknown claim is ignored). Without
112
165
  `CHEST_TOKEN` or `CHEST_TOOL`, nobody is a member. The function never throws
113
166
  for what a request carries.
114
167
 
@@ -127,9 +180,93 @@ member among those the manifest declares. Only the Chest's front reaches the
127
180
  container: the signature is a second defence; business rules (who writes
128
181
  what) remain the tool's.
129
182
 
183
+ `language` is the member's own language in the Chest, else the Chest's
184
+ default: a BCP 47 primary tag among those the product speaks (`en`, `fr`
185
+ today; the SDK accepts any, so a language added to the Chest needs no new
186
+ SDK). The tool's private part (`/chest`) speaks it — to this member, on every
187
+ request — and offers no language switch of its own; only its public parts,
188
+ where nobody is signed in, keep their own switch. The members API answers
189
+ it too: a notification or an email to another member is written in *their*
190
+ language (`members.get(id).language`), not in the sender's. A tool that does not
191
+ speak that language uses its own default. `timeZone` is the zone the member
192
+ works in: the one they chose in their profile, else the one their browser
193
+ is in, else the Chest's. The members API answers it too, so a tool reminds
194
+ each member at their own hour. What is the same for every member — the
195
+ organization, the company's time zone — is not the member's: it is the
196
+ Chest's (below).
197
+
198
+ ### Times: store in UTC, decide in the Chest's zone, show in the member's
199
+
200
+ | What | Zone |
201
+ |---|---|
202
+ | An instant (created, due at, sent at) | stored as UTC: `timestamptz` in PostgreSQL, `Date` in code |
203
+ | “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) |
204
+ | 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 |
205
+
206
+ ```ts
207
+ const who = member(request)!;
208
+ const due = await sql`select * from tasks where due_on = ${chest.today()}`; // the company's day
209
+ const shown = new Intl.DateTimeFormat(who.language, { timeZone: who.timeZone, dateStyle: "medium", timeStyle: "short" }).format(task.remindAt);
210
+ ```
211
+
212
+ ## `chest` — the Chest the tool runs in
213
+
214
+ ```ts
215
+ import { chest } from "@argentic/chest-sdk/chest";
216
+
217
+ chest.organization.name; // "Acme SAS": the organization the Chest is of, as its owner wrote it
218
+ chest.timeZone; // "Europe/Paris": an IANA zone, "UTC" until the owner sets one
219
+ chest.language; // "fr": the Chest's own language (a member's is member(request).language)
220
+ chest.currency; // "EUR": the Chest's currency, ISO 4217
221
+ chest.tool.teamUrl; // "https://tasks-chest.acme.argentic.work": where members open /chest
222
+ chest.tool.publicUrl; // "https://status.acme.com": the public part (its custom domain), or null
223
+ chest.today(); // "2026-09-30": the date now in the Chest's zone (or chest.today(at))
224
+ ```
225
+
226
+ The Chest gives these to every tool in its environment at each start
227
+ (`CHEST_ORGANIZATION`, `CHEST_TIME_ZONE`, `CHEST_LANGUAGE`, `CHEST_CURRENCY`,
228
+ `CHEST_TEAM_URL`, `CHEST_PUBLIC_URL`), and starts every tool that is awake
229
+ again when one changes — the owner changes the first four in Settings →
230
+ General; a custom domain served, or no longer, changes the public address —;
231
+ a tool asleep reads them when it wakes. The tool never asks its own admin for
232
+ the company's name, zone or currency, nor guesses its own address from a
233
+ request. They are there outside a
234
+ request too: a scheduled job, a start-up task, an export. No capability is
235
+ needed: nothing here is more than what the Chest's pages show its members.
236
+
237
+ - `organization.name` is plain text of 2 to 80 characters: show it in a
238
+ header, a document or an email, never as HTML.
239
+ - `timeZone` is the day of “due today” and the hour of a reminder. The Chest
240
+ also makes it the `TimeZone` of the tool's database sessions: there,
241
+ `current_date`, `now()::date` and a `timestamptz` shown as text are in the
242
+ Chest's zone. A session may set its own (`SET TIME ZONE`), for itself.
243
+ - `language` is the language of what the tool writes for no one in
244
+ particular: a public page before the visitor chooses, an export's default.
245
+ A page of `/chest` speaks `member(request).language` instead.
246
+ - `currency` is the ISO 4217 code of the Chest's currency (`"EUR"` until
247
+ the owner sets one): the amounts of a quote, a price, an expense. Format
248
+ them with `Intl.NumberFormat(language, { style: "currency", currency:
249
+ chest.currency })`.
250
+ - `tool.teamUrl` and `tool.publicUrl` are origins, without a path: build a
251
+ link where no request tells the host — an email sent from a scheduled job,
252
+ a calendar feed — with `new URL("/chest/tasks/42", chest.tool.teamUrl)`.
253
+ `publicUrl` is the company's own domain once the owner connected one, else
254
+ the tool's public host; `null` for a tool without a public part. Store
255
+ paths in your data, never these origins: they change.
256
+ - `today(at?)` is `YYYY-MM-DD` in the Chest's zone, for now or for an instant
257
+ (`Date` or milliseconds): compare it with dates your database keeps as
258
+ `date`, never with `new Date().toISOString().slice(0, 10)`, which is UTC's.
259
+
260
+ Each value is read from the environment at each access, and checked: outside a
261
+ Chest (a development server without the variables), or for a value the Chest
262
+ never gives, reading it throws a `ChestError` with the code `not_in_chest` —
263
+ a wrong zone read silently is exactly what this module exists to prevent. In
264
+ tests, `fakeChest({chest: {organization, timeZone, language, currency,
265
+ teamUrl, publicUrl}})` sets them.
266
+
130
267
  ## `members` — who has the tool
131
268
 
132
- A v2 tool that declares `"capabilities": ["members"]` (approved like a
269
+ A tool that declares `"capabilities": ["members"]` (approved like a
133
270
  permission: “Sees the name, photo, role and groups of the members who have
134
271
  access to it.”) reads the members who have it, through the Chest's API
135
272
  (`CHEST_API`, as for files). `"members.email"`, a permission of its own that
@@ -146,8 +283,9 @@ const teams = await members.groups.list(); // [
146
283
 
147
284
  - **Who**: exactly the members who have the tool now — by a grant, a group,
148
285
  open to all, or because they run it (owner, admins, its builders);
149
- recomputed at every call. A member without access answers as an identifier
150
- that does not exist (`get` → `null`, `lookup` → `unknown`).
286
+ recomputed at every call. `list` and `get` see only them (`get` → `null`
287
+ for anyone else); `lookup` also names those the tool had who no longer
288
+ have it (below).
151
289
  - **`list({after, limit, q, role, group})`**: ordered by name (accents aside)
152
290
  then identifier; `limit` 100 by default, 500 at most; `next` is an opaque
153
291
  cursor for `after`, `null` after the last page. `q` finds the start of a
@@ -155,10 +293,14 @@ const teams = await members.groups.list(); // [
155
293
  —, whatever its case and accents; `role` and `group` keep the members of that
156
294
  role or group.
157
295
  - **`lookup(ids)`**: each identifier once, in the order given: `members`,
158
- `former` (`{id, name, status: "former"}`: someone who left the Chest after
159
- having the tool, so a record still reads “Camille Martin (former member)”;
160
- `{id, name: null, status: "erased"}` once the owner had their data erased,
161
- rendered “Former member”) and `unknown`. The SDK asks 200 at a time and
296
+ `former` — those the tool had who no longer have it: `{id, name, status:
297
+ "no_access"}`, a member of the Chest who lost access to the tool (“Léa
298
+ Dubois (no access)”: the laptops she holds, the goals that need a new
299
+ owner); `{id, name, status: "former"}`, someone who left the Chest, so a
300
+ record still reads “Camille Martin (former member)”; `{id, name: null,
301
+ status: "erased"}` once the owner had their data erased, rendered “Former
302
+ member” — and `unknown`: an identifier the tool never had (the Chest names
303
+ nobody the tool never had, not even a member of the Chest). The SDK asks 200 at a time and
162
304
  keeps each answer a minute in the process (5,000 at most); `forget()`
163
305
  empties it, and so does every event of the members' lifecycle
164
306
  (`events.handle`).
@@ -191,7 +333,7 @@ To search tasks by assignee name: `members.list({ q })` first, then
191
333
 
192
334
  ## `notifications` — badges and inbox items
193
335
 
194
- A v2 tool that declares `"capabilities": ["notifications"]` (approved like a
336
+ A tool that declares `"capabilities": ["notifications"]` (approved like a
195
337
  permission: “Shows counters and sends notifications, inside the Chest, to the
196
338
  members who have access to it.”) tells its members what needs their
197
339
  attention, inside the Chest only — no email, no push to a phone. Two
@@ -271,13 +413,13 @@ away by itself once handled.
271
413
 
272
414
  ## `events` — the members' lifecycle
273
415
 
274
- A v2 tool that holds `members` and declares `"receives": ["member.*"]` in its
416
+ A tool that holds `members` and declares `"receives": ["member.*"]` in its
275
417
  `chest.json` (approved like a permission: “Is told when the members who have
276
418
  access to it change or leave.”) is told, on its own `POST /chest-events`:
277
419
 
278
420
  | Event | `data` | When |
279
421
  |---|---|---|
280
- | `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`) |
422
+ | `member.updated` | `{id, changed: ("name" \| "photo" \| "role" \| "groups" \| "email" \| "language" \| "timeZone")[]}` | Something the tool sees of a member who has it changed (`email` only with `members.email`; `language` and `timeZone`: the language the Chest speaks to them and the zone they work in — a digest's words and hour) |
281
423
  | `access.revoked` | `{id}` | The member lost access to the tool but stays in the Chest |
282
424
  | `member.removed` | `{id}` | The member left the Chest: `lookup` now reads them `former` |
283
425
  | `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)` |
@@ -340,9 +482,118 @@ export async function POST(request: Request) {
340
482
  `ChestError` `erasure_not_found` (404: an erasure this tool was not told of)
341
483
  or `invalid_id` (400), `CapabilityNotGranted` (403), `Unavailable`.
342
484
 
485
+ ## `ai` — AI models through the Chest
486
+
487
+ A tool that declares the `ai` capability calls AI models through its
488
+ Chest. The Chest's owner connects OpenRouter with the company's own key;
489
+ the tool calls models by four **aliases**: `default`, `fast`, `smart`,
490
+ `embedding`, each led by the Chest to a model it chose (the owner may choose
491
+ another for `default`). The tool names an alias, never a provider's model:
492
+ the model changes for the whole Chest without touching code. The tool never
493
+ holds a key; the Chest meters every call against the tool's monthly cap.
494
+
495
+ ```jsonc
496
+ // chest.json
497
+ {
498
+ "capabilities": ["ai"],
499
+ "ai": { "monthly": 20, "models": ["default", "embedding"], "purpose": "Summarises support tickets" }
500
+ }
501
+ ```
502
+
503
+ | Key | Default | |
504
+ |---|---|---|
505
+ | `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 |
506
+ | `models` | `["default"]` | 1 to 4 of `default`, `fast`, `smart`, `embedding`: the only aliases the tool may call |
507
+ | `purpose` | required | 1 to 120 characters, shown at approval: “Uses AI models through the Chest, up to €20 a month” |
508
+
509
+ ```ts
510
+ import * as ai from "@argentic/chest-sdk/ai";
511
+
512
+ const r = await ai.chat({
513
+ model: "default",
514
+ messages: [{ role: "system", content: "Summarise in two sentences." }, { role: "user", content: ticket.text }],
515
+ maxTokens: 300, // 1 to 128,000; 4,096 when not said
516
+ member: who.id, // optional: attribution in the Chest's usage log
517
+ });
518
+ r.text; // "" when the model only called tools
519
+ r.usage; // { input, output, cached, cost } — cost in estimated euros
520
+
521
+ // Streamed: pieces as they come; breaking out of the loop ends the call.
522
+ for await (const chunk of ai.chat({ model: "fast", messages, stream: true, signal })) {
523
+ write(chunk.text); // chunk.toolCalls, chunk.finishReason, then chunk.usage last
524
+ }
525
+
526
+ // Tools: the model asks, the tool runs them and answers.
527
+ const step = await ai.chat({ model: "smart", messages, tools: [{ type: "function", function: { name: "lookup", parameters: schema } }] });
528
+ messages.push(step.message);
529
+ 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))) });
530
+
531
+ const { embeddings } = await ai.embed({ model: "embedding", input: ["first text", "second text"] }); // 1 to 256 texts
532
+ const mapped = await ai.models(); // [{ alias, model, provider, input, output }] — USD per million tokens
533
+ const month = await ai.usage(); // { month: "2026-09", spent, cap, resetsAt }
534
+ ```
535
+
536
+ - **`chat(options)`** takes the OpenAI Chat Completions request in camelCase:
537
+ `model`, `messages`, `maxTokens`, `temperature`, `topP`, `stop`, `tools`,
538
+ `toolChoice`, `responseFormat`, `parallelToolCalls`, `seed`,
539
+ `reasoningEffort`, plus `member`, `stream` and `signal`. Messages, tools and
540
+ response formats keep the OpenAI shape (images in `content` parts too); the
541
+ Chest translates them for each provider and never runs a tool. Without
542
+ `stream` it returns `Promise<ChatResult>` `{text, message, toolCalls,
543
+ finishReason, model, usage}` — `message` is the assistant's message to add
544
+ to the conversation, `toolCalls` are `{id, name, arguments}` with the
545
+ arguments as JSON text, `model` is the provider's model. With
546
+ `stream: true` it returns an `AsyncIterable<ChatChunk>` `{text, toolCalls?,
547
+ finishReason?, usage?}`: tool calls come in pieces (`{index, id?, name?,
548
+ arguments?}`: join the `arguments` of the same `index`), the usage in the
549
+ last chunk.
550
+ - **Bounds**: a request body of 10 MiB (images included), 16 MiB of answer, a
551
+ call ends after 10 minutes (streamed or not); 60 requests a minute and 8
552
+ streams at once per tool (`RateLimited`). An aborted `signal` throws its
553
+ reason.
554
+ - **The cap never overshoots**: before a call the Chest reserves its worst
555
+ case (input and `maxTokens`) against the tool's cap and the Chest's; a call
556
+ that does not fit is refused before anything is spent. Keep `maxTokens` to
557
+ what the answer needs.
558
+ - **`embed({model, input, dimensions?, member?})`** gives one vector per text,
559
+ in the order given, and the input tokens and cost.
560
+ - **`models()`** gives the aliases the tool declared that the owner mapped,
561
+ with their model, provider and prices; **`usage()`** the tool's month:
562
+ estimated euros spent, the cap in force, and when the month resets.
563
+
564
+ **AI can stop at any time** — the month's budget spent, no connector, the
565
+ provider down. Keep the tool usable without it:
566
+
567
+ ```ts
568
+ import { AiCapReached, AiUnavailable } from "@argentic/chest-sdk/errors";
569
+
570
+ let summary: string | null = null;
571
+ try {
572
+ summary = (await ai.chat({ model: "default", messages, maxTokens: 300 })).text;
573
+ } catch (error) {
574
+ if (!(error instanceof AiCapReached || error instanceof AiUnavailable)) throw error;
575
+ // summary stays null: show "AI features are paused" and keep the page working
576
+ }
577
+ ```
578
+
579
+ | Error | Code, status | When |
580
+ |---|---|---|
581
+ | `AiCapReached` | `cap_reached` 402 | The tool's (`scope: "tool"`) or the Chest's (`scope: "chest"`) monthly cap is spent, until `resetsAt` |
582
+ | `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`) |
583
+ | `AiModelNotAllowed` | `model_not_allowed` 403 | An alias the tool did not declare in `models` (the SDK refuses any other name before sending) |
584
+ | `CapabilityNotGranted` | `capability_not_granted` 403 | The version does not hold `ai`, or it was not approved |
585
+ | `AiRefused` | `content_refused` 422 | The provider's moderation refused the content |
586
+ | `RateLimited` | `rate_limited` 429 | 60 requests a minute or 8 streams at once |
587
+ | `TooLarge` | `too_large` 413 | A body beyond 10 MiB, or a context beyond the model's |
588
+ | `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) |
589
+ | `Unavailable` | `unavailable` 503 | The Chest not reached, or an answer that is not its own; in a stream, the stream cut |
590
+
591
+ An error in the middle of a stream is thrown where it comes, after the chunks
592
+ before it.
593
+
343
594
  ## `databaseUrl()` — database of a server tool
344
595
 
345
- A v2 tool that declares `"capabilities": ["database"]` in its `chest.json`
596
+ A tool that declares `"capabilities": ["database"]` in its `chest.json`
346
597
  gets a PostgreSQL database of its own (the capability is shown and approved
347
598
  like a permission, in the approval screen). The container has no network: its
348
599
  launcher listens on `127.0.0.1` and relays each connection to the Chest. The
@@ -378,7 +629,7 @@ previous version working — going back to the previous version undoes nothing.
378
629
 
379
630
  ## `files` — files of a server tool
380
631
 
381
- A v2 tool that declares `"capabilities": ["files"]` (approved like a permission)
632
+ A tool that declares `"capabilities": ["files"]` (approved like a permission)
382
633
  keeps private files **through its Chest**, never on its disk (the container's
383
634
  root is read-only). The launcher gives the tool
384
635
  `CHEST_API=http://127.0.0.1:<port>` — its own port, relayed to the Chest; the
@@ -389,7 +640,7 @@ its own files only.
389
640
  import * as files from "@argentic/chest-sdk/files";
390
641
  await files.put("photos/cat.png", bytes, "image/png"); // Uint8Array or text
391
642
  const file = await files.get("photos/cat.png"); // {data, type, size} or null
392
- const info = await files.stat("photos/cat.png"); // {name, type, size, updated, width?, height?} or null
643
+ const info = await files.stat("photos/cat.png"); // {name, type, size, sha256, updated, width?, height?} or null
393
644
  const { files: page, next } = await files.list({ prefix: "photos/" }); // 1000 per page
394
645
  await files.move("photos/cat.png", "archive/cat.png"); // atomic; replaces archive/cat.png
395
646
  await files.delete("archive/cat.png"); // true, or false if it did not exist
@@ -470,6 +721,18 @@ otherwise); thumbnails are made once, not counted in the quota. `stat` gives
470
721
  `width` and `height` for these images. Give a link to a member's browser,
471
722
  never to a public page.
472
723
 
724
+ Every file the Chest answers (`put`, `stat`, `list`, `move`) carries
725
+ `sha256`, the digest of its content in hex, as the Chest took it: compare
726
+ it, or detect a duplicate receipt, without reading the file again.
727
+
728
+ The SDK takes a link or an upload address from the Chest only in `https`
729
+ on the team host. The one exception is a fake Chest of the same process
730
+ (`@argentic/chest-sdk/testing`): while it runs, the links and uploads it
731
+ signs on its own origin, `http://127.0.0.1:<port>`, are taken too. Only the
732
+ testing module opens that exception, for that origin alone, until
733
+ `close()`; nothing in the environment does, so production code that never
734
+ imports the testing module never takes a local link.
735
+
473
736
  Errors: `CapabilityNotGranted` (a version without the capability, or no
474
737
  `CHEST_API`), `TooLarge` (413), `QuotaExceeded` (429), `Unavailable` (the
475
738
  Chest not reached, or an answer that is not its own: a write may or may not
@@ -477,6 +740,79 @@ have happened), `ChestError` for the rest (`invalid_type`, `no_thumbnail`,
477
740
  `not_found` for `url` and `move`…). Removing the tool removes its files; a new
478
741
  version keeps them.
479
742
 
743
+ ## `schedules` — work the tool does by itself
744
+
745
+ Nothing runs in a tool's container between requests — the Chest puts a tool
746
+ nobody uses to sleep —: a morning digest, reminders, a purge or a badge kept
747
+ true overnight come from the Chest, which calls the tool at set times. The
748
+ tool declares each schedule in its `chest.json`, a name and a cron line read
749
+ on the wall clock of the Chest's time zone (`chest.timeZone`), approved in
750
+ words (“Runs by itself: morning, weekdays at 7:30 AM”):
751
+
752
+ ```jsonc
753
+ // chest.json
754
+ { "schedules": [{ "name": "morning", "cron": "30 7 * * 1-5" }, { "name": "retry-mail", "cron": "*/15 * * * *" }] }
755
+ ```
756
+
757
+ ```ts
758
+ // app/chest-schedules/route.ts — at the root, outside /chest: the Chest calls
759
+ // it through the tool's launcher, never from a browser (its front answers 404 there).
760
+ import * as schedules from "@argentic/chest-sdk/schedules";
761
+ import { chest } from "@argentic/chest-sdk/chest";
762
+
763
+ export async function POST(request: Request) {
764
+ return new Response(null, { status: await schedules.handle(request, {
765
+ morning: async () => { await sendDigest(chest.today()); },
766
+ "retry-mail": () => retryOutbox(),
767
+ }, { seen }) });
768
+ }
769
+ ```
770
+
771
+ - **The line**: five fields — minute, hour, day of the month, month, day of
772
+ the week —, each numbers, `*`, ranges (`1-5`), lists (`1,15`) and steps
773
+ (`*/15`); Sunday is 0 or 7; no names nor `@daily`, one space between
774
+ fields. When both days are restricted, either one runs (as cron). A time
775
+ a change of clock skips runs once, shifted; a repeated one runs once.
776
+ - **Bounds** (the Chest's, checked when the manifest is read): 8 schedules,
777
+ names of 1 to 32 lowercase letters, digits and hyphens, each running 15
778
+ minutes apart at least; 5 minutes a run.
779
+ - **Approval**: running by itself is a permission, one sentence per
780
+ schedule. A later version that changes, adds or removes schedules of a
781
+ tool that already had one asks nothing more.
782
+ - **Delivery**: `POST /chest-schedules`, the tool woken first when it
783
+ sleeps, body `{id: "run_…", name, scheduledAt, attempt}` signed for this
784
+ tool (`Chest-Schedule` header, HS256 under a key derived from
785
+ `CHEST_TOKEN` with the label `Chest-Schedule v1` — the scheme of events,
786
+ under a key of its own —, naming the run and the SHA-256 of the body, 60
787
+ seconds). `scheduledAt` is the time the run stands for (UTC); a run asked
788
+ now stands for the time it was asked.
789
+ - **Answer once the work is done**, within 5 minutes: a 2xx is done; a 404
790
+ (a schedule without a handler) is given up at once; anything else, or no
791
+ answer, is delivered again, the same run with the same id, after 1, 5 and
792
+ 15 minutes (`attempt` 2 to 4), unless the next time of its schedule comes
793
+ first. Runs of one schedule never overlap: a time that comes while the
794
+ previous run still runs is skipped. A server that was stopped runs a
795
+ missed time once when it starts again — the latest, never a backlog.
796
+ Longer work: do a batch per run and keep your place in the database.
797
+ - **`handle(request, handlers, {seen?})`** answers the status to give the
798
+ Chest: 401 for what is not a run of the Chest for this tool, 404 for a
799
+ schedule without a handler, 204 for a run handled or one already in
800
+ `seen`. It reads the body (1 KiB at most): mount it before any body
801
+ parser. A handler that throws leaves the run unseen and `handle` throws:
802
+ answer 500, it comes again. `seen` is as for `events` (`events.memorySeen`
803
+ by default; a table of the tool's for runs that must never be done twice —
804
+ the same table serves both, the ids never meet). Make handlers idempotent
805
+ anyway.
806
+ - **`verify(request)`** is the run of a delivery, or `null`; for a tool that
807
+ routes runs itself.
808
+ - **The Chest's times, the members' zones**: a line is the company's clock.
809
+ To reach each member at *their* 8:00, run hourly (`0 * * * *`) and pick
810
+ the members whose local hour it is (`members.list`, `member.timeZone`).
811
+ - **Whoever runs the tool** sees each schedule on its overview — when it
812
+ runs next, its last runs and why one failed — and may **Run now**; an
813
+ agent reads `GET /api/v1/tools/<tool>/schedules` and runs one with
814
+ `POST /api/v1/tools/<tool>/schedules/run {name}` (a token that writes).
815
+
480
816
  ## `testing` — a tool's own tests
481
817
 
482
818
  `@argentic/chest-sdk/testing` is for tests, never imported by production code.
@@ -484,8 +820,8 @@ version keeps them.
484
820
  ```ts
485
821
  import { fakeChest, signAssertion, withMember } from "@argentic/chest-sdk/testing";
486
822
 
487
- const camille = { id: "mbr_k2qhx4mzc7v3b6nfp5r2t7w4ya", firstName: "Camille", lastName: "Martin", name: "Camille Martin", photo: null, role: "editor", isAdmin: false, isBuilder: false, groups: [] };
488
- const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications"] });
823
+ 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" };
824
+ const chest = await fakeChest({ members: [camille], capabilities: ["members", "files", "notifications", "ai"], ai: { reply: () => "Summary." } });
489
825
  const response = await handler(withMember(new Request("http://tool.test/chest/tasks"), camille));
490
826
  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);
491
827
  assert.deepEqual(chest.acknowledged, ["era_k2qhx4mzc7v3b6nfp5r2t7w4ya"]);
@@ -493,15 +829,21 @@ assert.deepEqual((await members.list()).members.map(m => m.id), [camille.id]);
493
829
  assert.ok(chest.files.has("reports/2026.pdf"));
494
830
  assert.deepEqual(chest.notifications, [{ member: camille.id, title: "New task", path: "/chest/tasks/42", key: "task:42" }]);
495
831
  assert.equal(chest.badges.get(camille.id), 1);
832
+ assert.equal(chest.ai[0]?.path, "/ai/chat");
833
+ assert.equal(await chest.run("morning", request => handler(request)), 204);
496
834
  await chest.close();
497
835
  ```
498
836
 
499
837
  | Function | Gives |
500
838
  |---|---|
501
- | `signAssertion(member, {token?, tool?, now?})` | A `Chest-Member` header value signed like the Chest's (the token and tool of the environment by default) |
502
- | `withMember(request, member, options?)` | The request carrying that assertion: a new Web `Request`, or the same Node request |
503
- | `fakeChest({members?, former?, groups?, capabilities?, receives?, files?})` | An HTTP server on `127.0.0.1` that sets `CHEST_API`, `CHEST_TOKEN`, `CHEST_TOOL` (`tool` unless set) and answers members, groups, files, badges, notifications and erasure acknowledgments with a Chest's bounds, quotas and errors; a capability left out answers 403 (`members`, `files` and `notifications` 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` |
839
+ | `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 |
840
+ | `withMember(request, member, options?)` | The request carrying that assertion (the options of `signAssertion`): a new Web `Request`, or the same Node request |
841
+ | `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_CURRENCY`, `CHEST_TEAM_URL`, `CHEST_PUBLIC_URL` (`chest: {organization, timeZone, language, currency, teamUrl, publicUrl}`: `"Test organization"`, `"UTC"`, `"en"`, `"EUR"`, `https://<tool>-chest.chest.test`, `https://<tool>.chest.test` by default; `publicUrl: null` for a tool without a public part) 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). `former: [{id, name?, status?}]` are those the tool had who no longer have it: `lookup` answers them `no_access`, `former` (by default) or `erased` |
842
+ | Links and uploads | The fake serves the team host's part of the files on its own origin (`chest.api`): a link from `files.url` opens the content it was signed for (the image itself for a thumbnail — a fake does not reduce it; `no_thumbnail` for a file that is not a JPEG, PNG, GIF or WebP image), until it expires or the file changes; an address from `files.uploadUrl` takes one `PUT`, within its life, of the types and size it names and whose first bytes are those of its type (403 `invalid_token`, 415 `type_refused`, 400 `type_mismatch`, 413 `too_large`, as the Chest's), named by the Chest in a folder (20 hex characters and the type's ending), and answers `201 {name, type, size}`. It checks no session: a test's `fetch` is the member's browser |
504
843
  | `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 |
844
+ | `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 |
845
+ | `chest.run(name, to, {id?, scheduledAt?, attempt?})` | Delivers a run of the schedule `name` (a new id, now and attempt 1 by default; name an id to deliver the same run twice) signed as the Chest signs it, to `to` — the tool's address (`POST <to>/chest-schedules`) or a function of a Web `Request` — and says the status it answered |
846
+ | `chest.ai` | The tool's calls to AI, `{path, body}` in order (`body` null for a `GET`) |
505
847
  | `chest.acknowledged` | The erasures the tool acknowledged, each once |
506
848
  | `chest.members`, `chest.groups`, `chest.files` | What the fake Chest holds, to change or assert on; its `members` are those who have the tool |
507
849
  | `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) |
@@ -510,7 +852,9 @@ await chest.close();
510
852
  ## Version
511
853
 
512
854
  The package version is `version` in `package.json` (semver), published by a
513
- tag `vX.Y.Z` (see `PUBLISHING.md`).
855
+ tag `vX.Y.Z` (see `PUBLISHING.md`). Its MAJOR.MINOR is the version of the
856
+ tool contract it is written for (`"chest"` in `chest.json`): 0.4.x for the
857
+ contract 0.4. A new contract version is a new MINOR of the SDK.
514
858
 
515
859
  ## The MCP server
516
860
 
@@ -528,15 +872,26 @@ that carry a manifest — never offers it.
528
872
 
529
873
  ```sh
530
874
  npm ci
531
- npm test # build dist/, compile the tests into build/, run them
532
- npm run check:package # npm pack, install into a temp project, import every subpath
533
- # from Node and through esbuild, type-check a TS consumer
875
+ npm test # build dist/, compile the tests into build/, check that
876
+ # contract/README.md says what contract.json says, run them,
877
+ # then check/'s (the chest command)
878
+ npm run check:package # npm pack both packages, the SDK under 200 KiB, install into a
879
+ # temp project, run chest check, import every subpath from Node
880
+ # and through esbuild, type-check a TS consumer
534
881
  ```
535
882
 
883
+ `contract/contract.json`, `check/check.wasm.gz` and
884
+ `check/check.wasm.sha256` are written by the Chest's repository
885
+ (`scripts/build-contract.mjs`) from the code that decides; never edit them
886
+ here. `npm run contract` renders the parts of `contract/README.md` they say;
887
+ the words around them are written here. `check/` is the workspace of
888
+ `@argentic/chest-check`, released with the SDK under the same version
889
+ (`PUBLISHING.md`); `check/src/cli.ts` is the `chest` command.
890
+
536
891
  `client/src` holds the modules, `client/index.ts` the package root,
537
892
  `client/test` the tests. `npm run build` compiles `client/index.ts`, the
538
- seven published modules (`errors`, `member`, `members`, `database`, `files`,
539
- `notifications`, `testing`) and the one they share (`api`, the Chest's API) —
893
+ nine published modules (`errors`, `member`, `members`, `database`, `files`,
894
+ `notifications`, `events`, `ai`, `testing`) and the one they share (`api`, the Chest's API) —
540
895
  TypeScript strict, ES2022, NodeNext — into `dist/`: ESM `.js`, `.d.ts` and
541
896
  their maps. `member.ts` imports nothing but `node:*`, so that a tool may copy
542
897
  it alone.