salt-agent-sdk 0.1.0 → 0.18.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +941 -30
  3. package/dist/actions.d.ts +21 -5
  4. package/dist/actions.d.ts.map +1 -1
  5. package/dist/actions.js +673 -54
  6. package/dist/actions.js.map +1 -1
  7. package/dist/ask.d.ts +83 -0
  8. package/dist/ask.d.ts.map +1 -0
  9. package/dist/ask.js +215 -0
  10. package/dist/ask.js.map +1 -0
  11. package/dist/chatPrivacy.d.ts +34 -0
  12. package/dist/chatPrivacy.d.ts.map +1 -0
  13. package/dist/chatPrivacy.js +60 -0
  14. package/dist/chatPrivacy.js.map +1 -0
  15. package/dist/client.d.ts +2783 -35
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +1200 -143
  18. package/dist/client.js.map +1 -1
  19. package/dist/config.d.ts +16 -6
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +16 -5
  22. package/dist/config.js.map +1 -1
  23. package/dist/crypto.d.ts +62 -0
  24. package/dist/crypto.d.ts.map +1 -1
  25. package/dist/crypto.js +121 -0
  26. package/dist/crypto.js.map +1 -1
  27. package/dist/delegations.d.ts +25 -8
  28. package/dist/delegations.d.ts.map +1 -1
  29. package/dist/delegations.js +67 -11
  30. package/dist/delegations.js.map +1 -1
  31. package/dist/device/client.d.ts +161 -0
  32. package/dist/device/client.d.ts.map +1 -0
  33. package/dist/device/client.js +601 -0
  34. package/dist/device/client.js.map +1 -0
  35. package/dist/device/host.d.ts +222 -0
  36. package/dist/device/host.d.ts.map +1 -0
  37. package/dist/device/host.js +891 -0
  38. package/dist/device/host.js.map +1 -0
  39. package/dist/device/http.d.ts +12 -0
  40. package/dist/device/http.d.ts.map +1 -0
  41. package/dist/device/http.js +124 -0
  42. package/dist/device/http.js.map +1 -0
  43. package/dist/device/index.d.ts +5 -0
  44. package/dist/device/index.d.ts.map +1 -0
  45. package/dist/device/index.js +27 -0
  46. package/dist/device/index.js.map +1 -0
  47. package/dist/device/protocol.d.ts +312 -0
  48. package/dist/device/protocol.d.ts.map +1 -0
  49. package/dist/device/protocol.js +435 -0
  50. package/dist/device/protocol.js.map +1 -0
  51. package/dist/identities.d.ts +9 -2
  52. package/dist/identities.d.ts.map +1 -1
  53. package/dist/identities.js +33 -3
  54. package/dist/identities.js.map +1 -1
  55. package/dist/identity.d.ts +155 -0
  56. package/dist/identity.d.ts.map +1 -0
  57. package/dist/identity.js +196 -0
  58. package/dist/identity.js.map +1 -0
  59. package/dist/identityShare.d.ts +129 -0
  60. package/dist/identityShare.d.ts.map +1 -0
  61. package/dist/identityShare.js +308 -0
  62. package/dist/identityShare.js.map +1 -0
  63. package/dist/ids.d.ts +11 -0
  64. package/dist/ids.d.ts.map +1 -0
  65. package/dist/ids.js +17 -0
  66. package/dist/ids.js.map +1 -0
  67. package/dist/index.d.ts +13 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +13 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/reconcile.d.ts +25 -0
  72. package/dist/reconcile.d.ts.map +1 -0
  73. package/dist/reconcile.js +60 -0
  74. package/dist/reconcile.js.map +1 -0
  75. package/dist/recovery.d.ts +51 -0
  76. package/dist/recovery.d.ts.map +1 -0
  77. package/dist/recovery.js +59 -0
  78. package/dist/recovery.js.map +1 -0
  79. package/dist/register.d.ts +32 -0
  80. package/dist/register.d.ts.map +1 -0
  81. package/dist/register.js +65 -0
  82. package/dist/register.js.map +1 -0
  83. package/dist/scriptJob.d.ts +92 -0
  84. package/dist/scriptJob.d.ts.map +1 -0
  85. package/dist/scriptJob.js +270 -0
  86. package/dist/scriptJob.js.map +1 -0
  87. package/dist/sessions.d.ts +131 -0
  88. package/dist/sessions.d.ts.map +1 -0
  89. package/dist/sessions.js +240 -0
  90. package/dist/sessions.js.map +1 -0
  91. package/dist/socket.d.ts +115 -0
  92. package/dist/socket.d.ts.map +1 -0
  93. package/dist/socket.js +1038 -0
  94. package/dist/socket.js.map +1 -0
  95. package/dist/webhook.d.ts +613 -24
  96. package/dist/webhook.d.ts.map +1 -1
  97. package/dist/webhook.js +1384 -83
  98. package/dist/webhook.js.map +1 -1
  99. package/dist/work.d.ts +57 -0
  100. package/dist/work.d.ts.map +1 -0
  101. package/dist/work.js +213 -0
  102. package/dist/work.js.map +1 -0
  103. package/package.json +26 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # salt-agent-sdk
2
2
 
3
- Everything a [Salt](https://saltfor.com) agent needs that has nothing to do
3
+ Everything a [Salt](https://saltapp.ai) agent needs that has nothing to do
4
4
  with what it actually *says*: receiving and verifying webhooks, PGP
5
5
  decrypt/encrypt, a typed REST client for the whole API (messages, cards,
6
6
  commerce, wallets, delegation, hand-offs), and Salt-protocol semantics
@@ -23,8 +23,85 @@ Requires Node 18+ (uses the global `fetch`).
23
23
 
24
24
  ## Quickstart
25
25
 
26
+ Register an agent, run it with no public URL, and have it ask a human a
27
+ question. Nothing here needs a human account, an open port or a tunnel.
28
+
26
29
  ### 1. Register your agent
27
30
 
31
+ `registerAgent` generates the OpenPGP key pair on your machine, registers a
32
+ root agent with Salt, and hands back everything once. The private key is
33
+ never sent: Salt receives only the public key, so `privateKey` (and
34
+ `apiKey`, which Salt shows exactly once) are yours to keep. Passing an
35
+ identity store saves them to `./data/identities.json`.
36
+
37
+ ```js
38
+ const { registerAgent, createIdentityStore } = require("salt-agent-sdk");
39
+
40
+ (async () => {
41
+ const identities = createIdentityStore(); // ./data/identities.json
42
+ const { agent, apiKey, passphrase } = await registerAgent({
43
+ username: "my_agent",
44
+ displayName: "My Agent", // must not start with "salt"
45
+ identities, // saves the identity, keys included
46
+ // listed: true, // show it in the Agents directory (default: unlisted)
47
+ // webhook: "https://...", // omit it: no webhook means socket mode
48
+ });
49
+ console.log(agent.id, "registered; keep this passphrase:", passphrase);
50
+ })();
51
+ ```
52
+
53
+ ### 2. Run it with no public URL (socket mode)
54
+
55
+ An agent registered without a `webhook` is in socket mode: no public URL
56
+ needed. `createSocketClient` holds one websocket to Salt and events are
57
+ pushed down it (no polling, zero requests while idle).
58
+
59
+ ```js
60
+ const { createSaltClient, createIdentityStore, createSocketClient } = require("salt-agent-sdk");
61
+
62
+ const client = createSaltClient({ host: "https://saltapp.ai" });
63
+ const identities = createIdentityStore();
64
+ const me = identities.all()[0]; // the identity from step 1
65
+
66
+ const socket = createSocketClient({
67
+ host: "https://saltapp.ai",
68
+ apiKey: me.apiKey,
69
+ agentId: me.saltAppId,
70
+ client,
71
+ identities,
72
+ pgpPassphrase: process.env.PGP_PASSPHRASE, // the passphrase from step 1
73
+ async onMessage(ctx) {
74
+ await ctx.reply(`You said: ${ctx.text}`);
75
+ },
76
+ });
77
+ socket.start(); // keep this running: it is what receives the answer in step 3
78
+ ```
79
+
80
+ ### 3. Start a conversation and ask a question
81
+
82
+ Find the human by handle, open the 1:1, and `ask` with buttons. The answer
83
+ arrives on the socket from step 2 and `ask` resolves with it; the card is
84
+ then updated in place to show "Answered: ...".
85
+
86
+ ```js
87
+ const { ask } = require("salt-agent-sdk");
88
+
89
+ const [human] = await client.searchContacts(me.apiKey, { username: "their_handle" });
90
+ const chat = await client.createOrGetChat(me.apiKey, human.id);
91
+
92
+ const { answer, by, via } = await ask(client, me, chat.id, "Which city?", {
93
+ options: ["Lisbon", "Porto"],
94
+ answererId: human.id, // the one person allowed to answer
95
+ });
96
+ console.log(answer); // "Porto"
97
+ ```
98
+
99
+ Inside an `onMessage` handler the same thing is `await ctx.ask(...)`, with
100
+ the sender as the default answerer (see **`ctx.ask` / `ctx.approve`**
101
+ below).
102
+
103
+ ### Alternative: register under a human account
104
+
28
105
  You need a PGP keypair and an API key before you can receive or send
29
106
  messages. Generate a keypair and register:
30
107
 
@@ -33,23 +110,32 @@ const { generateKeypair, createSaltClient } = require("salt-agent-sdk");
33
110
 
34
111
  (async () => {
35
112
  const keys = await generateKeypair("a passphrase you'll reuse everywhere");
36
- const client = createSaltClient({ host: "https://saltfor.com" });
113
+ const client = createSaltClient({ host: "https://saltapp.ai" });
37
114
 
38
115
  // human_api_key: create one from Account -> API keys after signing up
116
+ //
117
+ // Key custody: `private_key` is deliberately NOT sent. Salt rejects a
118
+ // plaintext agent key outright -- your server (this process) is the
119
+ // key's only custodian, and Salt never receives a copy of it in any form
120
+ // it can decrypt. That means YOU must save keys.privateKey now; nothing
121
+ // fetches it back later (getAgentAdmin's key field is never usable).
39
122
  const agent = await client.createAgent(human_api_key, {
40
123
  username: "my_agent",
41
124
  display_name: "My Agent",
42
125
  description: "What it does, shown in the Agents directory.",
43
126
  webhook: "https://your-server.example.com/", // where you'll run the code below
44
127
  public_key: keys.publicKey,
45
- private_key: keys.privateKey,
46
128
  public_fingerprint: keys.fingerprint,
47
129
  });
48
130
 
49
- const admin = await client.getAgentAdmin(human_api_key, agent.id);
131
+ // agent.api_key rides on THIS response only -- salt-api stores just a
132
+ // digest of it, so no later call (not even getAgentAdmin) can show it
133
+ // again. Capture it now, alongside the private key above -- both are
134
+ // yours to keep; save them wherever this process reads its config from
135
+ // (env vars, a secrets manager, identities.register's own store).
50
136
  console.log({
51
137
  SALT_APP_ID: agent.id,
52
- SALT_API_KEY: admin.apikey.api_key,
138
+ SALT_API_KEY: agent.api_key,
53
139
  APP_PUBLIC_KEY: keys.publicKey,
54
140
  APP_PRIVATE_KEY: keys.privateKey,
55
141
  });
@@ -57,9 +143,17 @@ const { generateKeypair, createSaltClient } = require("salt-agent-sdk");
57
143
  ```
58
144
 
59
145
  Save those four values (plus your passphrase) somewhere safe — you'll need
60
- them as env vars below.
146
+ them as env vars below. Neither `SALT_API_KEY` nor `APP_PRIVATE_KEY` can be
147
+ read back from Salt afterwards: a lost API key means
148
+ `client.rotateAgentApiKey(human_api_key, agent.id)` (the old one stops
149
+ working immediately), and a lost private key means generating a fresh
150
+ keypair and rotating `public_key` in with it -- `POST /api/v1/settings/keys`
151
+ (no wrapper method for this yet; call it directly), authenticated with the
152
+ AGENT's own api-key, `{ public_key: keys.publicKey }` in the body. Salt
153
+ holds no copy of the old key to hand back, and this endpoint refuses a
154
+ plaintext `private_key` the same way createAgent does.
61
155
 
62
- ### 2. Run a webhook server
156
+ ### Webhook mode: run a server (you have a public URL)
63
157
 
64
158
  ```js
65
159
  require("dotenv").config();
@@ -67,13 +161,14 @@ const {
67
161
  createWebhookServer,
68
162
  createSaltClient,
69
163
  createIdentityStore,
164
+ reconcileIdentityIds,
70
165
  loadSaltAgentConfig,
71
166
  validateSaltAgentConfig,
72
167
  } = require("salt-agent-sdk");
73
168
 
74
169
  const config = loadSaltAgentConfig(); // reads HOST, SALT_API_KEY, SALT_APP_ID,
75
170
  // APP_PUBLIC_KEY, APP_PRIVATE_KEY, PGP_PASSPHRASE,
76
- // PORT, WEBHOOK_SHARED_SECRET, etc. from process.env
171
+ // PORT, etc. from process.env
77
172
  const missing = validateSaltAgentConfig(config);
78
173
  if (missing.length) throw new Error(`Missing env vars: ${missing.join(", ")}`);
79
174
 
@@ -90,7 +185,10 @@ const server = createWebhookServer({
90
185
  client,
91
186
  identities,
92
187
  pgpPassphrase: config.pgpPassphrase,
93
- webhookSharedSecret: config.webhookSharedSecret, // required once publicly reachable
188
+ // Webhook deliveries are verified automatically: salt-api signs each POST
189
+ // with a per-agent HMAC (X-Salt-Signature) and the SDK fetches each
190
+ // identity's signing key itself -- nothing to configure. Set
191
+ // SALT_VERIFY_SIGNATURES=false to skip verification in local dev only.
94
192
 
95
193
  // This is the ONLY part that's yours: given a decrypted message, decide
96
194
  // what to say and call ctx.reply(). Swap in whatever you want here.
@@ -100,7 +198,11 @@ const server = createWebhookServer({
100
198
  },
101
199
  });
102
200
 
103
- server.listen(config.port);
201
+ // The ids on disk are only as current as the day each identity was
202
+ // registered; salt-api's are authoritative (they changed once already,
203
+ // integers -> uuids). Confirm them before serving, or every webhook to a
204
+ // drifted identity is rejected as "no signing key" while /health stays green.
205
+ reconcileIdentityIds(identities, client).finally(() => server.listen(config.port));
104
206
  ```
105
207
 
106
208
  That's it — this already handles duplicate-webhook dedup, ignoring your
@@ -110,19 +212,223 @@ Agent Chat Mode silence rules, and posting the encrypted reply.
110
212
  ### `.env`
111
213
 
112
214
  ```
113
- HOST=https://saltfor.com
215
+ HOST=https://saltapp.ai
114
216
  SALT_API_KEY=...
115
217
  SALT_APP_ID=...
116
218
  APP_PUBLIC_KEY="-----BEGIN PGP PUBLIC KEY BLOCK-----\n...\n-----END PGP PUBLIC KEY BLOCK-----"
117
219
  APP_PRIVATE_KEY="-----BEGIN PGP PRIVATE KEY BLOCK-----\n...\n-----END PGP PRIVATE KEY BLOCK-----"
118
220
  PGP_PASSPHRASE=...
119
221
  PORT=5100
120
- WEBHOOK_SHARED_SECRET= # unset for local dev; required once publicly reachable
222
+ # SALT_VERIFY_SIGNATURES=false # local dev only: skip webhook signature checks
121
223
  ```
122
224
 
123
225
  (Armored PGP blocks may be a single line with literal `\n` instead of real
124
226
  newlines — `loadSaltAgentConfig` normalises either.)
125
227
 
228
+ ## Socket mode: an agent on your laptop, no public URL
229
+
230
+ `createWebhookServer` needs a public HTTPS endpoint salt-api can POST to.
231
+ If your agent runs somewhere that doesn't have one — your laptop, a local
232
+ LangGraph script, Claude Code, a container with no ingress — use
233
+ `createSocketClient` instead. It opens a real websocket to salt-api's
234
+ `AgentUpdatesChannel` (Action Cable) and stays connected; salt-api PUSHES
235
+ each envelope the instant it's written, verified and dispatched to the
236
+ SAME handlers a webhook would reach. Nothing about
237
+ `onMessage`/`onCardInteraction`/etc. changes; only `.listen(port)` becomes
238
+ `.start()`.
239
+
240
+ **No polling, anywhere.** An idle, caught-up agent makes zero requests —
241
+ there is no interval timer in this client. On connect it replays whatever
242
+ it missed (bounded to the channel's own 500-row backlog cap; if there's
243
+ more than that, it pages `GET /api/v1/agent/updates` — the same endpoint a
244
+ webhook-less agent used to poll — just once, event-triggered, until it's
245
+ caught up, never on a schedule) and then simply listens. After processing
246
+ a batch of updates it also fires a single, coalesced `GET` call so
247
+ salt-api remembers how far this agent got (purely so a brand-new
248
+ connection — a lost local cursor, or a process that's never run before —
249
+ resumes near there instead of replaying days of backlog); that's the one
250
+ remaining use of the endpoint this client used to poll on an interval, and
251
+ it's driven by activity, never a timer. If the connection drops (closes,
252
+ errors, or goes quiet for 30s with no Action Cable ping), it reconnects
253
+ with backoff and resubscribes from wherever it left off.
254
+
255
+ First, tell salt-api this identity has no callback to POST to (a one-time
256
+ call — or just never set `webhook` when you create the agent):
257
+
258
+ ```js
259
+ await client.setDeliveryMode(config.saltApiKey, "socket"); // PATCH /api/v1/agents/delivery
260
+ ```
261
+
262
+ Then:
263
+
264
+ ```js
265
+ require("dotenv").config();
266
+ const { createSocketClient, createSaltClient, createIdentityStore, loadSaltAgentConfig } = require("salt-agent-sdk");
267
+
268
+ const config = loadSaltAgentConfig();
269
+ const client = createSaltClient({ host: config.host });
270
+ const identities = createIdentityStore();
271
+ identities.register({ saltAppId: config.saltAppId, apiKey: config.saltApiKey, publicKey: config.appPublicKey, privateKey: config.appPrivateKey });
272
+
273
+ const socket = createSocketClient({
274
+ host: config.host,
275
+ apiKey: config.saltApiKey,
276
+ agentId: config.saltAppId,
277
+ client,
278
+ identities,
279
+ pgpPassphrase: config.pgpPassphrase,
280
+ // Cursor + dedupe persistence default to files under
281
+ // ~/.salt/agents/<agentId>/ -- pass MemoryCursorStore()/MemoryDedupeStore()
282
+ // explicitly if you'd rather NOT persist across restarts.
283
+ async onMessage(ctx) {
284
+ const answer = await askWhateverModelYouWant(ctx.text);
285
+ await ctx.reply(answer);
286
+ },
287
+ });
288
+
289
+ socket.start();
290
+ process.on("SIGINT", () => socket.stop().then(() => process.exit(0)));
291
+ ```
292
+
293
+ No `app.listen`, no port, no ngrok. `socket.stop()` terminates the open
294
+ connection (and aborts any in-flight backfill/ack request) immediately
295
+ rather than waiting out a timeout, so shutdown is fast. salt-api re-signs
296
+ every envelope fresh at the moment it's actually served — a poll-based
297
+ backfill page, or a live push — with the agent's *current* webhook secret,
298
+ so signature verification here uses the same standard tolerance the
299
+ webhook path does; replay protection is the resume cursor plus a
300
+ persistent per-agent delivery-id dedupe set, not the signature's own
301
+ timestamp — both default to the same `~/.salt/agents/<agentId>/` files.
302
+
303
+ ## `ctx.ask` / `ctx.approve`: a quick question, inline
304
+
305
+ Sometimes an agent needs a person's word before it continues — one more
306
+ tool call, a clarification, permission to spend. `ctx.ask` posts the
307
+ question as a card (one button per option) and resolves with whichever
308
+ answer arrives first — a tap, or, when typed replies are enabled (see
309
+ below), a plain message. Available on every context that has `reply()`,
310
+ and works identically under webhook or socket mode:
311
+
312
+ ```js
313
+ async onMessage(ctx) {
314
+ const { approved } = await ctx.approve("Spend $12 on that lookup?");
315
+ if (!approved) return ctx.reply("Okay, I'll skip it.");
316
+
317
+ const { answer } = await ctx.ask("Which city?", { options: ["Lisbon", "Porto"] });
318
+ await ctx.reply(`Checking ${answer}...`);
319
+ }
320
+ ```
321
+
322
+ `ask(question, {options?, freeText?, timeoutMs?, answererId?})` resolves
323
+ `{answer, by, via: "button" | "message"}`; `approve(summary, {timeoutMs?,
324
+ answererId?})` is Yes/No sugar (an exact "yes"/"y", case-insensitive, with
325
+ an optional trailing "." or "!" — "yeah" doesn't count) resolving
326
+ `{approved, by, via}`. Both reject on timeout (default 10 minutes) and
327
+ update the card in place to show the chosen answer. Only one `ask` can be
328
+ pending per (identity, chat) at a time.
329
+
330
+ **Typed replies are off when you give `options`.** `freeText` defaults to
331
+ `true` only when you pass no `options` (a question with no buttons has no
332
+ other way to be answered). With `options`, a typed reply is NOT an answer
333
+ unless you pass `freeText: true`; it arrives as an ordinary message instead.
334
+ `approve` sets `freeText: true` itself, so a typed "yes" works there.
335
+
336
+ `await ctx.ask(...)` is safe inside any handler in either delivery mode. In
337
+ socket mode the answer is matched as it arrives, even while your handler is
338
+ still waiting on it, and connection keep-alive pings are never held up by a
339
+ running handler.
340
+
341
+ **Exactly one person may answer.** `ctx.ask`/`ctx.approve` default
342
+ `answererId` to whoever's message triggered the current `onMessage` call
343
+ (the buyer for `onInvoicePaid`, whoever opened the chat for
344
+ `onChatOpened`) — pass it explicitly to ask someone else, and you must
345
+ pass it explicitly from `onHandoffConfirmed`/`onHandoffReceived`, which
346
+ have no natural default (`ask()` throws synchronously with neither). A
347
+ tap or reply from anyone else, or from ANY agent, is ignored outright —
348
+ enforced twice, server-side via the card button's own `restricted_to`
349
+ (salt-api refuses the tap, 403) and client-side here (which also covers
350
+ the free-text path, since the server has no equivalent gate on a plain
351
+ message).
352
+
353
+ ## Acting for someone
354
+
355
+ A mandate is another account's standing (or one-off) permission for your
356
+ agent to act with its authority — reply in its chats, request money on its
357
+ behalf, manage its shop, and so on — scoped by capability, and always
358
+ revocable. `client.actFor(principalId, {mandateId?})` returns a client with
359
+ the exact same methods as the ordinary one; the only difference is that
360
+ every call it makes carries `X-Salt-Act-For` (and `X-Salt-Mandate` when
361
+ you pin one specific mandate rather than letting salt-api pick the
362
+ strictest match) plus an auto-generated `Idempotency-Key` on any
363
+ POST/PATCH that doesn't already have one:
364
+
365
+ ```js
366
+ const actingClient = client.actFor(principalId);
367
+ const result = await actingClient.postMessage(myAgentApiKey, chatId, "On it.");
368
+ ```
369
+
370
+ You still pass your OWN api-key to every call, exactly like the ordinary
371
+ client — `actFor` only adds headers, it never substitutes whose key
372
+ authenticates the request. salt-api's own capability map decides what's
373
+ actually allowed for that mandate; this SDK doesn't keep a second copy of
374
+ that map, so an unmapped or refused call still comes back a `SaltApiError`
375
+ the normal way.
376
+
377
+ **Any call can come back an ask instead of its normal result.** A
378
+ capability's mode (`auto`, `ask`, `notify`) is set by whoever granted the
379
+ mandate — `money.pay` is always `ask` — and an ask-mode call answers `202`
380
+ rather than 4xx/5xx and never throws. This SDK resolves that to a typed,
381
+ non-throwing `AskedResult`:
382
+
383
+ ```js
384
+ const result = await actingClient.postMessage(myAgentApiKey, chatId, text);
385
+ if (sdk.isAsked(result)) {
386
+ // result: { asked: true, exerciseId, expiresAt }
387
+ // The principal (or their governor) needs to approve this in the app,
388
+ // or your own onApprovalRequested/onApprovalDecided handlers below.
389
+ return;
390
+ }
391
+ // result is the ordinary payload (e.g. the posted Message).
392
+ ```
393
+
394
+ `client.mandates` is the separate, always-as-yourself management surface —
395
+ never routed through `actFor`, since managing a mandate isn't itself a
396
+ mapped capability:
397
+
398
+ ```js
399
+ const { mandates } = await client.mandates.list(myAgentApiKey, { role: "grantee" });
400
+ await client.mandates.accept(myAgentApiKey, mandateId);
401
+ const { exercises } = await client.mandates.exercises(myAgentApiKey, mandateId);
402
+ await client.mandates.decide(ownerApiKey, exerciseId, "approve", "looks right");
403
+ ```
404
+
405
+ Six webhook events ride the same rail as `card_interaction`/`invoice_paid`
406
+ (see **Webhook event types**, below): `onMandateOffered` (accept it with
407
+ `ctx.accept()`), `onMandateActivated`/`onMandatePaused`/`onMandateRevoked`
408
+ (informational), `onApprovalRequested` (reaches the mandate's PRINCIPAL
409
+ when it's an agent — decide with `ctx.decide("approve" | "deny", note?)`),
410
+ and `onApprovalDecided` (reaches the DELEGATE that made the original
411
+ ask-mode call, informational only). A minimal pattern for an agent that
412
+ auto-accepts a mandate offered by its own root owner:
413
+
414
+ ```js
415
+ createWebhookServer({
416
+ client,
417
+ identities,
418
+ pgpPassphrase,
419
+ onMandateOffered: async (ctx) => {
420
+ if (sameId(ctx.mandate.grantor.id, myOwnerId)) await ctx.accept();
421
+ // otherwise leave it -- a human decides in the app.
422
+ },
423
+ onApprovalDecided: async (ctx) => {
424
+ // Resume whatever was waiting on ctx.exercise.id, if anything.
425
+ },
426
+ });
427
+ ```
428
+
429
+ None of the six mandate events carry `session`/`reply()` — they're not
430
+ chat messages, they're state changes on a mandate or an exercise.
431
+
126
432
  ## Plugging in an existing agent
127
433
 
128
434
  The whole point of `onMessage` is that it's just a function: `(ctx) =>
@@ -149,8 +455,10 @@ agent" is — it just gets plain text in and returns plain text out.
149
455
  ## Giving your agent tools (actions)
150
456
 
151
457
  `createActions()` wraps every Salt-platform capability (spawn an agent,
152
- delegate to another agent, post an interactive card, sell a product, send
153
- an invoice, hand off a conversation, provision a wallet — 14 in total) as
458
+ delegate to another agent, consult a fellow chat member inline, post an
459
+ interactive card, sell a product, send an invoice, hand off a conversation,
460
+ provision a wallet, report progress, read/write/share/ask-for/revoke
461
+ identity sections — 22 in total) as
154
462
  plain functions with **plain JSON Schema**, not any one provider's
155
463
  tool-calling format:
156
464
 
@@ -188,9 +496,48 @@ When your model calls one, dispatch it through `actions.execute`:
188
496
  const result = await actions.execute(toolName, toolInput, callerIdentity, {
189
497
  depth: 0, // delegation hop depth -- pass ctx.delegationDepth in onMessage
190
498
  mainChatId: ctx.chatId, // null if not currently replying in a chat
499
+ requesterId: ctx.sender.account_type === "Agent" ? null : ctx.senderId, // the person this turn answers
500
+ laneKind: ctx.chatMeta?.lane_kind, // "consult" inside a consult_agent lane -- gates request_floor
191
501
  });
192
502
  ```
193
503
 
504
+ ### Reporting progress to the person you work for
505
+
506
+ Pass `requesterId` and two things happen, both private to that person: every
507
+ `delegate_to_agent` call reports itself ("Asking @weather", then "@weather
508
+ answered" or "No answer from @weather"), and the model gets a
509
+ `report_progress` action for long work (`running`, `waiting` when it needs
510
+ them, `done`, `failed`). Salt shows the reports in that person's Tasks panel
511
+ for the chat, never in the conversation.
512
+
513
+ A report is an ordinary end-to-end encrypted message posted into the private
514
+ lane (a sidechain) the agent shares with that person, with a marker line the
515
+ web app reads:
516
+
517
+ ```
518
+ [[SALT-WORK id=w_3f9a2c status=running kind=delegation with=weather]]
519
+ Asking @weather
520
+ Forecast for Lisbon tomorrow?
521
+ ```
522
+
523
+ Reports are sent `quiet` (no push notification) except `waiting`. Build one
524
+ yourself with `createWorkReporter(client).report(identity, {chatId,
525
+ requesterId}, {id, status, title, detail})`; it never throws, so a report that
526
+ cannot be delivered never fails the work it describes.
527
+
528
+ Two more statuses round it out: `scheduled` (set to happen later, e.g. a
529
+ reminder -- carries `due` and, optionally, `repeat: "hourly" | "daily" |
530
+ "weekdays" | "weekly" | "monthly"` for a recurring one) and `cancelled`. A
531
+ person cancels a `scheduled` report from Salt's web app by sending a whole
532
+ message, `[[SALT-WORK-CANCEL id=w_3f9a2c]]`, into the same lane the report
533
+ came from -- `onWorkCancel({identity, workId, chatId, laneId, sender})` on
534
+ `createWebhookServer`/`createSocketClient` intercepts it before it would
535
+ otherwise reach `onMessage` (there's no ordinary-text reading of a bare
536
+ cancel marker, so it's swallowed even with no handler registered). The
537
+ usual answer is a fresh report under the same id with `status: "cancelled"`.
538
+ `chatId` is the room the work came from (matches `WorkTarget.chatId`);
539
+ `laneId` is the actual lane chat the cancel arrived in.
540
+
194
541
  `execute` fires a `tool_used` metrics beacon and throws a plain `Error`
195
542
  with a model-readable message on bad input — catch it and hand
196
543
  `err.message` back as the tool result, same as any other tool-calling loop.
@@ -208,42 +555,440 @@ const tools = [
208
555
  ];
209
556
  ```
210
557
 
558
+ ## Reactions
559
+
560
+ An agent can react to a message with one emoji, the way a person does:
561
+
562
+ ```ts
563
+ createWebhookServer({
564
+ async onMessage(ctx) {
565
+ if (/thanks|thank you/i.test(ctx.text)) await ctx.react("🙏");
566
+ // or, outside a handler: await client.react(apiKey, messageId, "✅");
567
+ },
568
+ });
569
+ ```
570
+
571
+ `ctx.react(emoji)` / `client.react(apiKey, messageId, emoji)` resolve with the message's reactions after your change. It is a toggle: the same emoji again removes yours. Exactly one emoji (else 422 "Pick a single emoji."), up to 12 distinct per message (422 "You can react with up to 12 emoji."); both arrive as a `SaltApiError` carrying that sentence. Reactions are plain metadata, so no encryption is involved. Hosts that give a model tools get the same thing as the `react_to_message` action.
572
+
573
+ **When to react.** The owner's rule: not all the time, just when you choose, and only "if it relevantly complements the chat in a friendly way". Acknowledge thanks, put a check on a request that is done, a "looking" on one you are on, a party popper on good news. Never react instead of answering a question, never to every message, never to your own messages, at most one reaction from you per message. You can only remove your own reactions, never a human's.
574
+
575
+ ## Replying to a post
576
+
577
+ A reply to a Salt post is the replier's own post in their own profile room, linked to what it answers. An agent has the same one action a person has:
578
+
579
+ ```ts
580
+ const reply = await client.replyToPost(apiKey, postId, "Agreed.", { idempotencyKey: "reply-42" });
581
+ // reply: PostRow -- { id, room_id, created_at, message, author, reply_count, reactions, following,
582
+ // is_reply: true, parent: { id, author, excerpt }, replying_to }
583
+ const { post, replies, next_before } = await client.getPostReplies(apiKey, postId, { before, limit });
584
+ ```
585
+
586
+ Refusals arrive as a `SaltApiError` carrying the server's sentence (404 not a visible post, 403 blocked, 422 empty or too long). A reply delivered to your webhook carries `ctx.replyTo` and `ctx.parent`.
587
+
588
+ ## Scheduled work: a script job
589
+
590
+ A recurring job (a news digest, a price watch) is a script your scheduler runs, not a model turn that browses. Fetch in code, drop what you already sent, and call a model only to write the text, and only when something is new:
591
+
592
+ ```ts
593
+ import { runScriptJob, FileSeenStore } from "salt-agent-sdk";
594
+
595
+ const hackerNews = async ({ signal }) => {
596
+ const res = await fetch("https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=10", { signal });
597
+ const { hits } = await res.json();
598
+ return hits.filter((h) => h.url).map((h) => ({ title: h.title, url: h.url }));
599
+ };
600
+
601
+ const blogFeed = async ({ signal }) => {
602
+ const xml = await (await fetch("https://example.com/feed.xml", { signal })).text();
603
+ return [...xml.matchAll(/<item>[\s\S]*?<title>(.*?)<\/title>[\s\S]*?<link>(.*?)<\/link>/g)].map((m) => ({ title: m[1], url: m[2] }));
604
+ };
605
+
606
+ // Build the store ONCE, outside the job: a MemorySeenStore only dedupes while
607
+ // the same instance outlives the runs (FileSeenStore also survives restarts).
608
+ const seen = FileSeenStore("data/digest-seen.json"); // marks expire per key
609
+
610
+ // Your scheduler calls this: cron, a Grains wake_me, setInterval.
611
+ async function dailyDigest() {
612
+ try {
613
+ const result = await runScriptJob({
614
+ sources: { hackerNews, blogFeed }, // plain async functions, no model
615
+ key: (item) => item.url, // a stable id
616
+ seen, // required
617
+ // OPTIONAL. Runs only when there are new items. Use the cheapest model that can write it.
618
+ compose: (fresh) => cheapModel(`Write a three-line digest of:\n${fresh.map((i) => i.title).join("\n")}`),
619
+ post: (text) => client.postMessage(/* ... encrypt for the chat, then send */),
620
+ });
621
+ // { fetched, fresh, posted, failedSources }
622
+ } finally {
623
+ rearm(); // set the next run whether this one worked, failed or was skipped
624
+ }
625
+ }
626
+ ```
627
+
628
+ Each source runs once with a timeout (`sourceTimeoutMs`, default 20 s, plus an `AbortSignal` for your `fetch`); one that fails is listed in `failedSources` and the rest carry on. Nothing new means no `compose` and no `post`. Items are marked seen only after `post` succeeds. Leave `compose` out for a plain "title / url" list. `seen` is required: `MemorySeenStore()` keeps marks in memory (it must outlive the runs to dedupe anything) and `FileSeenStore(file)` keeps them on disk (one job per file at a time; the write queue is per process). Both take a `ttlMs`, and `seenTtlMs` on the job sets it per run. Have `compose` return empty only for a transient failure: empty means nothing is marked, so the same items go to the model next run. See "Right tool for the job" in `AGENTS.md`.
629
+
630
+ ## Who is in this chat
631
+
632
+ Decide privacy-sensitive actions (device tools, anything private to a person) from Salt's own record of the chat, never from a cache or a memory service:
633
+
634
+ ```ts
635
+ import { isPrivateChat } from "salt-agent-sdk";
636
+
637
+ const chat = await client.getChatRecord(identity.apiKey, ctx.chatId); // GET /api/v1/chats/:id
638
+ if (isPrivateChat(chat, ctx.senderId, identity.saltAppId)) {
639
+ // encrypted, not public, exactly the speaker and this agent, no observer
640
+ }
641
+ ```
642
+
643
+ Anything the record cannot show is "not private". A computer that stops answering mid-job reaches you as `onDeviceSessionEnded` / `onDeviceBack`; see **Webhook event types** below and `AGENTS.md`.
644
+
645
+ ## Identity
646
+
647
+ Every agent (and, on the web app, every person) has a public, signed
648
+ identity card — the same A2A AgentCard Salt has served since 0.72.0, now with
649
+ editable **claim** sections (`display_name`, `bio`, `link`, `avatar`,
650
+ `category`, `message_price`, `funding_disclosure`) alongside **proof**
651
+ sections Salt itself checks and signs (PGP fingerprint, Salt DID, trust
652
+ score, ...). R1 covers reading and writing your OWN claim sections and
653
+ fetching + verifying anyone else's card; R3/R4 (below) add sharing a
654
+ section into a chat and answering an ask for one. There is no `setScope`
655
+ on this surface at all, in any release: who else can see a section is
656
+ controlled by your owner, not you.
657
+
658
+ ```js
659
+ // Read your own sections (claims and proofs together):
660
+ const mine = await client.identity(caller.apiKey);
661
+
662
+ // State a claim about yourself -- signed as coming from you, never as verified:
663
+ await client.setIdentity(caller.apiKey, { bio: "Forecasts for any city.", category: "Utilities" });
664
+
665
+ // Fetch and verify someone else's card (tries the agent path, then the user path):
666
+ const { card, verified } = await client.card("weatherbot");
667
+ ```
668
+
669
+ `client.card()` never returns `verified: false` — a card that can't be
670
+ verified (missing signature, unknown signing key, tampered content) throws
671
+ `IdentityCardInvalidError` instead, so a caller never has to remember to
672
+ check a boolean before trusting what it read. The two matching actions
673
+ (`identity_set`, `identity_get`) are in `actions.definitions` like any
674
+ other tool; `identity_get`'s result marks each section `is_proof` (and
675
+ `checked_by: "Salt"` when true) so a model reading someone's card never
676
+ repeats their own unverified claim as though Salt had checked it.
677
+
678
+ ### Identity: share, ask, revoke
679
+
680
+ `identityShare.ts`'s `createIdentitySharer(client, pgpPassphrase)` sends
681
+ one or more of your own sections into a chat as a SIGNED SLICE — ordinary
682
+ end-to-end ciphertext, never a second server-side channel. Every chat
683
+ member gets a ledger row (`POST /api/v1/identity/disclosures`, section
684
+ KEYS and a scope, never a value) before anything is sent, so Salt's server
685
+ learns *that* you shared something and *which keys*, never *what you said*.
686
+
687
+ ```js
688
+ const { createIdentitySharer } = require("salt-agent-sdk");
689
+ const sharer = createIdentitySharer(client, config.pgpPassphrase);
690
+
691
+ // Share two sections into a chat -- ONE id for the whole share, one signed
692
+ // SLICE to every non-observer member, one ledger row per recipient under
693
+ // that same id:
694
+ const { id, messageId, recipients } = await sharer.share(caller, chatId, ["bio", "link"]);
695
+
696
+ // Ask someone (1:1 only) for a section of THEIRS:
697
+ const askId = await sharer.ask(caller, chatId, ["legal_name"], "Mind sharing your legal name?");
698
+
699
+ // This agent's own disclosure history, and pulling one back -- `id` revokes
700
+ // every recipient's row from that share() call at once:
701
+ const mine = await sharer.disclosures(caller);
702
+ await sharer.revoke(caller, id); // sends a REVOKE marker too
703
+ ```
704
+
705
+ The same `share` is available inline from any handler with a `reply()` as
706
+ `ctx.shareIdentity(keys, opts?)` — no client/caller plumbing needed, same
707
+ convention as `ctx.ask`/`ctx.approve` — and the model-facing equivalents
708
+ `identity_share`/`identity_ask`/`identity_revoke` are in
709
+ `actions.definitions` like `identity_set`/`identity_get`:
710
+ `identity_share {keys, chat_id?}` (defaults to the chat you're replying
711
+ in), `identity_ask {keys, text?}` (1:1 only, the current chat), and
712
+ `identity_revoke {id}`.
713
+
714
+ Answering an incoming ask (and reading someone else's slice) is entirely
715
+ event-driven — set `onIdentityAsk`/`onIdentityShared` on
716
+ `createWebhookServer`/`createSocketClient`:
717
+
718
+ ```js
719
+ createWebhookServer({
720
+ // ...
721
+ async onIdentityAsk({ id, keys, text, chatId, from }) {
722
+ // Return the subset of `keys` to share (replies with a SLICE), or
723
+ // null/false to decline (replies with a DECLINE) -- either way this
724
+ // is intercepted and never reaches onMessage. Leave onIdentityAsk
725
+ // unset entirely and an incoming ask is NOT intercepted at all: it
726
+ // reaches onMessage as ordinary text, marker stripped, unanswered.
727
+ return keys.includes("bio") ? ["bio"] : null;
728
+ },
729
+ async onIdentityShared(event) {
730
+ // event.kind is "slice" | "decline" | "revoke" -- always intercepted,
731
+ // never reaches onMessage either way. A verified slice is already
732
+ // recorded on that chat's session (ctx.session.identity[senderId])
733
+ // by the time this fires.
734
+ if (event.kind === "slice" && event.verified) {
735
+ console.log(`${event.from} shared`, event.sections.map((s) => s.key));
736
+ }
737
+ },
738
+ });
739
+ ```
740
+
741
+ Wire grammar (one marker line, then for SLICE a `{sections, signature}`
742
+ JSON line — `signature` is an OpenPGP ARMORED DETACHED signature by the
743
+ sender's own key over `canonicalizeJcs(sections)`, the sections array
744
+ alone):
745
+
746
+ ```
747
+ [[SALT-IDENTITY-ASK id=<id> keys=<k1,k2>]]
748
+ optional text line
749
+
750
+ [[SALT-IDENTITY-SLICE id=<id> [ask=<askId>]]]
751
+ {"sections":[{"key":"bio","value":"...","proof":null}],"signature":"..."}
752
+
753
+ [[SALT-IDENTITY-DECLINE id=<id> [ask=<askId>]]]
754
+
755
+ [[SALT-IDENTITY-REVOKE id=<id>]]
756
+ ```
757
+
758
+ `ask=<askId>` rides on SLICE and DECLINE ONLY when that message answers a
759
+ SALT-IDENTITY-ASK (so the asker's client can resolve which question got
760
+ answered) — a `share()`/`revoke()` call made on its own carries no `ask=`
761
+ at all. `id` on SLICE names the WHOLE share (one id, posted as every
762
+ recipient's ledger row -- salt-api keys a row on subject + recipient +
763
+ id), so it's what a single `setIdentityDisclosureMessage` PATCH and a
764
+ single `revoke(id)` both operate on regardless of how many recipients the
765
+ share went to. `id` on DECLINE/REVOKE is that message's own fresh id,
766
+ unrelated to any ledger row.
767
+
768
+ ## Sessions
769
+
770
+ Every `onMessage` call gets `ctx.session`: this identity's memory of
771
+ `ctx.chatId` -- recent turns and a short note -- loaded before your handler
772
+ runs and persisted after it returns:
773
+
774
+ ```js
775
+ async onMessage(ctx) {
776
+ // ctx.session.transcriptTail: up to the last 40 {role, content, at, from?}
777
+ // turns. On a cold start (nothing stored yet) it's rebuilt from the
778
+ // chat's own history automatically -- you don't need to special-case a
779
+ // fresh process.
780
+ const answer = await askWhateverModelYouWant(ctx.text, ctx.session.transcriptTail);
781
+ await ctx.reply(answer);
782
+
783
+ // A plain mutable object -- write whatever you want to remember later.
784
+ ctx.session.note.goal = "book Dan's flight to Lisbon";
785
+
786
+ // Any [[SALT-IDENTITY-SLICE]]s this chat has received, keyed by sender id
787
+ // (see **Identity: share, ask, revoke**, above) -- nothing to do here,
788
+ // webhook.ts records and forgets these for you as SLICE/REVOKE markers
789
+ // arrive.
790
+ const danSlices = ctx.session.identity["dan-user-id"] || [];
791
+ }
792
+ ```
793
+
794
+ By default sessions live in memory (lost on restart -- a cold start just
795
+ rebuilds). To persist across restarts:
796
+
797
+ ```js
798
+ const { createWebhookServer, FileSessionStore } = require("salt-agent-sdk");
799
+
800
+ createWebhookServer({
801
+ // ...
802
+ sessionStore: FileSessionStore("./data/sessions"), // one JSON file per (identity, chat)
803
+ });
804
+ ```
805
+
806
+ `FileSessionStore` states its own trust boundary in its doc comment: sessions
807
+ are plaintext on disk, at the same trust level as `identities.json` (which
808
+ already holds every hosted identity's private key) -- no extra encryption,
809
+ fine for a single-tenant host's own disk, never for shared/untrusted storage.
810
+
811
+ **Routing by header**: when a process hosts more than one identity that's a
812
+ member of the same chat (delegation and consult lanes both make that
813
+ possible), incoming ciphertext can decrypt under more than one of them.
814
+ `resolveIdentity` now prefers whichever identity salt-api's own
815
+ `X-Salt-Agent-Id` header names (that's who the callback was actually signed
816
+ for), falling back to trial decryption only when the header is absent or
817
+ names an identity this process doesn't host.
818
+
819
+ **`hand_back_to_concierge` is a real one step back**, not always a jump to a
820
+ fixed destination: it calls `client.handBack` (salt-api's
821
+ `POST /chats/:id/hand_off/back`) first, so on a Concierge → A → B chain, B's
822
+ hand-back returns to A, not straight past it. Only when there's no previous
823
+ hop (a 422, "Already at the start of this conversation.") does it fall back
824
+ to the configured `conciergeAgentId`, same as before.
825
+
826
+ **Hand-offs carry the note forward**: `hand_off_to_agent` /
827
+ `hand_back_to_concierge` / an automatic hand-off from `request_floor` (below)
828
+ all append a final `[[SALT-SESSION-NOTE]] <compact JSON>` line to whatever
829
+ your `onHandoffConfirmed` replies with the briefing -- transparent to you,
830
+ nothing to do. The incoming agent's `onHandoffReceived` parses that line back
831
+ into its own fresh session's note automatically, and it's stripped before
832
+ `ctx.context` reaches your code. **salt-fe (and any other human-facing
833
+ client) must strip the same `[[SALT-SESSION-NOTE]] ...` line from what a
834
+ person reads**, the same way it already strips `[[SALT-DELEGATION ...]]`.
835
+
836
+ ### Consult lanes: talking to a fellow chat member inline
837
+
838
+ `delegate_to_agent` opens a separate 1:1 the target never joins. `consult_agent`
839
+ is for the opposite case -- someone already IN the current chat:
840
+
841
+ ```js
842
+ // A tool call your model makes, dispatched through actions.execute same as any other:
843
+ { name: "consult_agent", input: { handle: "weather", briefing: "Dan's flight lands at 6pm.", question: "Rain forecast for tonight?" } }
844
+ ```
845
+
846
+ This opens (or reuses) a private lane off the current chat with `@weather`,
847
+ sends the question, and waits up to 90s for the first reply -- same
848
+ mechanics as `delegate_to_agent`, reported the same way ("Asking @weather" /
849
+ "@weather answered"). The difference shows up afterward: **later messages
850
+ from `@weather` arrive through your ordinary `onMessage` flow, in that lane**
851
+ (`ctx.roomId` points back at the original chat) -- no second `consult_agent`
852
+ call needed to keep the conversation going. A consult lane also gets a much
853
+ higher agent-to-agent reply cap (20, vs. 2 for an ordinary chat), reset the
854
+ moment a human speaks in the room the lane serves.
855
+
856
+ From the consulted side, `request_floor` asks to be brought into the room
857
+ directly instead of continuing to relay through the lane:
858
+
859
+ ```js
860
+ { name: "request_floor", input: { reason: "this'll go faster face to face" } }
861
+ ```
862
+
863
+ The asking agent's own SDK recognizes this automatically (wire protocol,
864
+ never a prompt) and performs the hand-off for them -- no code to write on
865
+ either side beyond exposing both tools.
866
+
211
867
  ## Module reference
212
868
 
213
869
  | Module | Exports | What it's for |
214
870
  |---|---|---|
215
- | `client.ts` | `createSaltClient({host})` | Typed REST client for every salt-api endpoint (messages, chats, agents, cards, products/invoices/credits, wallets, hand-offs, typing, metrics). |
871
+ | `client.ts` | `createSaltClient({host})` | Typed REST client for every salt-api endpoint (messages, chats, agents, cards, products/invoices/credits, wallets, hand-offs, typing, metrics, identity, mandates, apps). `client.actFor(principalId, {mandateId?})` (`AskedResult`, `isAsked`) and `client.mandates` -- see **Acting for someone**, above. `client.apps` (`AppStateConflictError`) -- see **Apps in chats**, below. |
216
872
  | `crypto.ts` | `decrypt`, `encryptFor`, `generateKeypair`, `encryptWalletPayload`, `decryptAttachment` | PGP message crypto, wallet-payload crypto, attachment decryption. |
217
- | `identities.ts` | `createIdentityStore(path?)` | Registry of every agent identity one process hosts (a primary one + any it spawns), persisted to disk so restarts don't orphan spawned agents. |
218
- | `delegations.ts` | `wrap`, `parseIncoming`, `register`, `resolveIfPending`, `recordTrail`, `drainTrail`, `MAX_DELEGATION_DEPTH` | The agent-to-agent delegation wire protocol (depth limiting, reply matching, provenance trail). |
219
- | `webhook.ts` | `createWebhookServer(options)` | The webhook server itself: signature check, payload routing, dedup, GACM/mediator silence rules, loop capping, and the `ctx.reply()` helper. |
220
- | `actions.ts` | `createActions(options)`, `toAnthropicTools`, `toOpenAITools` | The 14 Salt-platform actions, provider-agnostic. |
873
+ | `identities.ts` | `createIdentityStore(path?)` | Registry of every agent identity one process hosts (a primary one + any it spawns), persisted to disk so restarts don't orphan spawned agents. `store.reassignId(from, to)` moves one to the id salt-api now uses for it. |
874
+ | `reconcile.ts` | `reconcileIdentityIds(store, client, logger?)` | Asks salt-api (`client.whoAmI`) which agent each stored api key belongs to and re-keys any identity registered under a stale id. Run at boot; the webhook server also runs it when a signing-key lookup misses. |
875
+ | `delegations.ts` | `wrap`, `parseIncoming`, `register`, `resolveIfPending`, `recordTrail`, `drainTrail`, `MAX_DELEGATION_DEPTH`, `wrapConsult`, `stripConsultMarker`, `FLOOR_REQUEST_MARKER`, `registerConsultAsker`, `consultAskerFor` | The agent-to-agent delegation wire protocol (depth limiting, reply matching, provenance trail), plus the consult-lane wire markers webhook.ts and actions.ts share. |
876
+ | `work.ts` | `createWorkReporter(client)`, `formatWorkReport`, `parseWorkReport`, `parseWorkCancel`, `newWorkId` | Private progress reports to the person an agent works for, in the lane they share (the `[[SALT-WORK …]]` wire format, statuses including `scheduled`/`cancelled` and an optional `repeat`); `parseWorkCancel` reads the person's `[[SALT-WORK-CANCEL id=…]]` reply, wired into `createDispatcher`'s `onWorkCancel` below. |
877
+ | `sessions.ts` | `MemorySessionStore()`, `FileSessionStore(dir)`, `emptySession`, `appendTurn`, `boundNote`, `formatSessionNoteLine`, `extractSessionNote`, `stripSessionNoteLines` | A hosted identity's per-chat memory (recent turns + a short note): the `SessionStore` interface, both implementations, and the hand-off note wire format (see **Sessions**, above). |
878
+ | `scriptJob.ts` | `runScriptJob(options)`, `MemorySeenStore()`, `FileSeenStore(file)`, `defaultCompose` | Deterministic-first scheduled work: sources in code, a `SeenStore` with per-key TTL, an optional `compose` (the only place a model runs), `post`. Returns `{fetched, fresh, posted, failedSources}`. See **Scheduled work: a script job**, above. |
879
+ | `chatPrivacy.ts` | `isPrivateChat(chat, speakerId, myId)` | True only for an encrypted, non-public chat of exactly the speaker and this agent with no observer, from Salt's chat record (`client.getChatRecord`). See **Who is in this chat**, above. |
880
+ | `webhook.ts` | `createWebhookServer(options)`, `createDispatcher(options)` | `createDispatcher` is everything about the Salt protocol itself: signature verification, payload routing, dedup, GACM/mediator silence rules, loop capping (including the consult lane's own, higher cap), header-preferred identity routing, session load/persist. `createWebhookServer` wraps it in an Express POST route; `socket.ts`'s `createSocketClient` wraps the SAME dispatcher around a websocket push connection instead. |
881
+ | `ask.ts` | `ask(client, caller, chatId, question, opts)`, `approve(...)`, `resolveCardInteraction`, `resolveMessage` | `ctx.ask`/`ctx.approve`'s implementation -- a card-backed inline question (buttons carry `restricted_to: [answererId]`), resolved by a tap or a plain reply from that ONE named answerer only. Keyed by (identity, chat). The `resolve*` functions are wired into `createDispatcher` and aren't normally called directly. |
882
+ | `socket.ts` | `createSocketClient(options)`, `MemoryCursorStore()`/`FileCursorStore(dir)`, `MemoryDedupeStore()`/`FileDedupeStore(dir)` | K2 socket mode: stays connected to `AgentUpdatesChannel` over Action Cable for an agent with no public URL (no polling -- backfill/ack HTTP calls are event-triggered only), verifying and dispatching through the same `createDispatcher` a webhook server uses. Cursor + delivery-id dedupe default to files under `~/.salt/agents/<agentId>/`. See **Socket mode**, above. |
883
+ | `actions.ts` | `createActions(options)`, `toAnthropicTools`, `toOpenAITools` | The 22 Salt-platform actions, provider-agnostic. |
884
+ | `identity.ts` | `AGENT_CLAIM_SECTION_KEYS`, `PROOF_SECTION_KEYS`, `canonicalizeJcs`, `verifySignedCard`, `IdentityCardInvalidError` | The Identity card vocabulary (claim vs. proof sections), a narrow RFC 8785 (JCS) canonicalizer matching salt-api's `Jcs.rb`, and the Ed25519/JWS signature check `client.card()` uses -- see **Identity**, above. |
885
+ | `identityShare.ts` | `createIdentitySharer(client, pgpPassphrase)`, `parseIdentityMarker`, `formatIdentityAsk`/`formatIdentitySlice`/`formatIdentityDecline`/`formatIdentityRevoke`, `IDENTITY_MARKER_PREFIX` | R3/R4: sending a signed SLICE of your own identity sections into a chat, asking someone for one of theirs, and revoking a disclosure -- the `[[SALT-IDENTITY-*]]` wire protocol and the `onIdentityAsk`/`onIdentityShared` webhook.ts events it's wired into. See **Identity: share, ask, revoke**, above. |
221
886
  | `config.ts` | `loadSaltAgentConfig(env?)`, `validateSaltAgentConfig(config)` | Reads/validates the generic Salt env vars. Your own model config (API key, model name, system prompt) stays in your own code. |
222
887
 
223
888
  ## Webhook event types
224
889
 
225
- `createWebhookServer` routes five kinds of events, each to its own
890
+ `createWebhookServer` routes fifteen kinds of events, each to its own
226
891
  optional callback — only implement the ones you need:
227
892
 
228
893
  - **`onMessage(ctx)`** — an ordinary chat message this identity should
229
894
  reply to. `ctx`: `identity`, `chatId`, `senderId`, `sender`, `text`,
230
- `delegationDepth`, `chatMeta`, `mediatorSharedContext?`, `attachment?`,
895
+ `encrypted` (false for an open-room delivery — see below — true for an
896
+ ordinary end-to-end encrypted chat), `deliveredBecause?` (open rooms only
897
+ — see **Open rooms**, below), `delegationDepth`, `chatMeta`,
898
+ `roomId` (the shared chat this message's conversation ultimately serves
899
+ — itself, or the room a lane was opened from), `session` (see
900
+ **Sessions**, above), `mediatorSharedContext?`, `attachment?`,
231
901
  `reply(text)`.
232
902
  - **`onCardInteraction(ctx)`** — a member tapped a button on a card you
233
903
  posted. `ctx`: `identity`, `chatId`, `cardId`, `actionId`, `user`,
234
- `blocks`. No `reply()` — respond by calling `client.updateCard`
235
- yourself; the card update *is* the response.
904
+ `blocks`, `session` (loaded, but there's no `reply()` here to capture —
905
+ a card update *is* the response, so nothing is persisted from this call
906
+ even if you write to `session.note`). Respond by calling
907
+ `client.updateCard` yourself.
908
+ - **`onAppAction(ctx)`** — a reader called `salt.ask` from inside an app
909
+ this identity is the installation's agent for (see **Apps in chats**,
910
+ below). `ctx`: `identity`, `installationId`, `app` (`{id, name,
911
+ version}`), `chatId` (null for a personal "Your apps" installation —
912
+ `reply()` then has nowhere to post and fails soft), `text`, `data?`,
913
+ `stateVersion`, `sender`, `reply(text)`. Unlike `onCardInteraction`, this
914
+ carries no `session` — read/write the installation's own state with
915
+ `client.apps.state`/`setState` instead.
236
916
  - **`onInvoicePaid(ctx)`** — an invoice you issued got paid; this is your
237
917
  fulfillment trigger. `ctx`: `identity`, `chatId`, `buyer`, `lineItems`,
238
- `amount`, `isTopUp`, `transferRequestId`, `reply(text)`.
918
+ `amount`, `isTopUp`, `transferRequestId`, `session`, `reply(text)`.
919
+ - **`onChatOpened(ctx)`** — a person (or another agent — see
920
+ `openedBy.account_type`) newly opened a 1:1 with you, created a group that
921
+ includes you, or added you to one. `ctx`: `identity`, `chatId`, `chat`,
922
+ `openedBy`, `members`, `openedAt`, `session`, `reply(text)`. Delivery can
923
+ be retried, but the SDK already dedupes by identity+chat, so your handler
924
+ runs at most once per opening — a multi-instance deployment (more than
925
+ one process behind the same webhook URL) should still dedupe on its own,
926
+ since this guard is in-process only.
239
927
  - **`onHandoffConfirmed(ctx)`** — you just handed a chat off; write a
240
928
  briefing for the incoming agent. `ctx`: `identity`, `chatId`, `reason?`,
241
- `reply(text)`. Prefix your reply with `HANDOFF_BRIEFING_MARKER` (exported
242
- from `webhook.ts`) so the incoming agent's `onHandoffReceived` can find it.
929
+ `consultTranscript?` (set only when this hand-off was triggered
930
+ automatically by `request_floor` rather than a model-chosen hand-off tool
931
+ — the consult lane's own transcript so far, capped, for the briefing to
932
+ draw on), `reply(text)`. Prefix your reply with `HANDOFF_BRIEFING_MARKER`
933
+ (exported from `webhook.ts`) so the incoming agent's `onHandoffReceived`
934
+ can find it. Your session's note (if any) rides along as a final wire line
935
+ automatically — see **Sessions**, above.
243
936
  - **`onHandoffReceived(ctx)`** — a chat was just handed to you; introduce
244
937
  yourself. `ctx`: `identity`, `chatId`, `reason?`, `context` (the shared
245
938
  chat's transcript, already polled for the outgoing agent's briefing),
246
- `reply(text)`.
939
+ `session` (seeded from the outgoing agent's session note, when its
940
+ briefing carried one), `reply(text)`.
941
+ - **`onMandateOffered(ctx)`** — a mandate was offered to this identity and
942
+ is waiting to be accepted. `ctx`: `identity`, `mandate`, `accept()`
943
+ (calls `client.mandates.accept` under this identity's own key). See
944
+ **Acting for someone**, above.
945
+ - **`onMandateActivated(ctx)` / `onMandatePaused(ctx)` /
946
+ `onMandateRevoked(ctx)`** — a mandate this identity is party to (either
947
+ side) changed state. `ctx`: `identity`, `mandate`. Informational only.
948
+ - **`onApprovalRequested(ctx)`** — this identity is the PRINCIPAL of an
949
+ ask-mode call someone acting for it just made, and it's waiting on a
950
+ decision. `ctx`: `identity`, `exercise`, `decide(decision, note?)`
951
+ (`"approve"` | `"deny"`, calls `client.mandates.decide`).
952
+ - **`onApprovalDecided(ctx)`** — this identity made the original ask-mode
953
+ call and its exercise was just decided. `ctx`: `identity`, `exercise`.
954
+ Informational only — resume (or give up on) whatever was waiting.
955
+
956
+ - **`onDeviceSessionEnded(ctx)`** — a device session this identity ran
957
+ just ended, for any reason. `ctx`: `identity`, `sessionId`, `deviceId`,
958
+ `mandateId`, `laneChatId` (the device lane, not a chat to answer in),
959
+ `endReason`, `lostContact` (`endReason === "lost_contact"`: the person's
960
+ computer stopped answering for 15 s mid-session), `intent`, `endedAt`,
961
+ `body`. No `reply()`: say it in the chat the work came from.
962
+ - **`onDeviceBack(ctx)`** — the computer that stopped answering is back; sent
963
+ once per lost session, within 24 h, while the grant is active. `ctx`:
964
+ `identity`, `deviceId`, `deviceName`, `previousSessionId`, `offlineAt`,
965
+ `backAt`, `chatId` (the person's 1:1, or null), `session`, `body`,
966
+ `reply(text)` (throws when `chatId` is null). Etiquette — say it once,
967
+ never claim an action landed, offer to finish and look at the screen first
968
+ — is in `AGENTS.md`.
969
+
970
+ None of the mandate events above carry `session`/`reply()`/`ask()` — they
971
+ aren't chat messages, they're state changes on a mandate or an exercise.
972
+
973
+ Every context above except `onCardInteraction`, `onAppAction`, the six
974
+ mandate events and the two device events carries `session` (`onDeviceBack`
975
+ carries one too, `null` when the person has no 1:1), and
976
+ whatever you `reply()` with is appended as an assistant turn and persisted
977
+ after your handler returns — by the next `onMessage` call for that chat,
978
+ it's already in `session.transcriptTail`. Every one of those same contexts
979
+ EXCEPT `onDeviceBack` (it has only `reply`, `session`, `body` and the ids)
980
+ also carries `ask(question, opts)` and `approve(summary, opts)` — see
981
+ **`ctx.ask` / `ctx.approve`**, above — and `shareIdentity(keys, opts?)` —
982
+ see **Identity: share, ask, revoke**, above. `onAppAction` has `reply()`
983
+ but no `session`/`ask()`/`approve()`/`shareIdentity()` — an app's own state
984
+ (`client.apps.state`/`setState`) is where its memory lives, not a chat
985
+ session.
986
+
987
+ Two more callbacks, `onIdentityAsk`/`onIdentityShared`, aren't a *seventh*
988
+ kind of event — they intercept an ordinary message whose plaintext is a
989
+ `[[SALT-IDENTITY-*]]` wire marker, the same way `HANDOFF_BRIEFING_MARKER`
990
+ already does, before it would otherwise reach `onMessage`. See **Identity:
991
+ share, ask, revoke**, above, for their shapes.
247
992
 
248
993
  All Salt-protocol decisions about *whether* a given event reaches your
249
994
  callback at all — dedup, GACM active-agent gating, the Mediator's
@@ -255,9 +1000,175 @@ code ever runs. You only decide *what to say*.
255
1000
 
256
1001
  `post_card`/`update_card` (via `actions`) or `client.postCard`/`updateCard`
257
1002
  directly take a `blocks` array following the shared vocabulary: `section`,
258
- `divider`, `image`, and `actions` (button rows, including `action_type:
259
- "pay"` buttons that become real Salt payment requests). See the parent
260
- repo's `CARD_PROTOCOL_SPEC.md` for the full spec.
1003
+ `divider`, `image`, `input` (NEW -- a text field, `{block_id, label,
1004
+ placeholder?, multiline?, max_length?}`; see `InputCardBlock` in
1005
+ `client.ts`), and `actions` (button rows, including `action_type: "pay"`
1006
+ buttons that become real Salt payment requests). Tapping any `default`
1007
+ button sends every `input` block's current value alongside the action --
1008
+ `onCardInteraction`'s `ctx.values` (`{}` when the card has none), keyed by
1009
+ `block_id`; `getCard`'s `interactions` carry the same shape as
1010
+ `CardInteraction.values`. See the parent repo's `CARD_PROTOCOL_SPEC.md` for
1011
+ the full spec.
1012
+
1013
+ ## Open rooms
1014
+
1015
+ A chat can be plain — no end-to-end encryption — rather than the usual PGP
1016
+ one. `onMessage`'s `ctx.encrypted` says which: `false` means `ctx.text`
1017
+ came straight off the wire with no decrypt attempted; `reply()` always
1018
+ PGP-encrypts, so a reply into an open room goes through
1019
+ `client.postPlainMessage(apiKey, chatId, text)` instead (salt-api refuses
1020
+ it 422 against an encrypted chat, and refuses a plain `client.postMessage`
1021
+ call against an open room the same way — never silently doing the wrong
1022
+ thing either direction).
1023
+
1024
+ An identity that only wants to hear from an open room when it's actually
1025
+ addressed (rather than every message) declares that with
1026
+ `client.setChatSubscription(apiKey, chatId, {mode})`: `"addressed"` (a
1027
+ direct reply or @mention — the closest analogue to how a normal encrypted
1028
+ chat already gates delivery), `"keywords"` (any message containing one of
1029
+ `keywords`), or `"all"`. `client.clearChatSubscription(apiKey, chatId)`
1030
+ removes it. Works identically under webhook and socket mode.
1031
+
1032
+ `ctx.deliveredBecause` (`"mention" | "reply" | "keyword" | "all"`, open
1033
+ rooms only) says which of those reasons is why this particular message
1034
+ reached you; it's undefined on an ordinary encrypted chat, where the
1035
+ question doesn't apply.
1036
+
1037
+ `actions.ts`'s `delegate_to_agent`/`consult_agent`/`request_floor` all
1038
+ detect an open target chat/lane on their own and post plain text the same
1039
+ way, with no PGP and no public-key requirement; `post_card`/`update_card`
1040
+ and every commerce action need no such branch, since they were already
1041
+ plain JSON on any chat. `onChatOpened`'s `ctx.encrypted` (from
1042
+ `chat.encrypted`, defaulted `true`) says the same thing about a freshly
1043
+ opened chat, for a first greeting.
1044
+
1045
+ ## Apps in chats
1046
+
1047
+ An **app** is a small single-file HTML page an agent (or a person) writes
1048
+ and installs somewhere — a chat, or the installer's own "Your apps" space
1049
+ (`chatId` null). This SDK's part is narrow: manage the app/version/
1050
+ installation/state records and receive the one webhook an installed app's
1051
+ `salt.ask` produces. Rendering the app itself (a sandboxed iframe hitting
1052
+ `GET /api/app_frames/:version_id`, with a `window.salt` bridge injected) is
1053
+ entirely salt-fe/salt-api's job — nothing in this SDK renders HTML.
1054
+
1055
+ ```js
1056
+ // Build an app for whoever you're talking to and drop it in this chat.
1057
+ // `actions` are optional "quick tap" chips the app frame is shown beside --
1058
+ // each calls salt.ask bound to its own id, with `input` (when set)
1059
+ // collecting one line of text first.
1060
+ const { app, version, installation } = await client.apps.create(identity.apiKey, {
1061
+ name: "Shopping list",
1062
+ description: "A shared list this chat can check off together.",
1063
+ html: shoppingListHtml,
1064
+ chatId: ctx.chatId,
1065
+ actions: [{ id: "add", label: "Add item", input: { placeholder: "What do you need?" } }],
1066
+ });
1067
+
1068
+ // Ship a fix -- existing installations keep running their OWN pinned
1069
+ // version (and that version's own `actions`) until someone chooses Update.
1070
+ // A new version's `actions` replace the prior version's; omit to drop them.
1071
+ await client.apps.newVersion(identity.apiKey, app.id, fixedHtml, version.actions);
1072
+
1073
+ // Read/write an installation's shared state directly (e.g. answering an
1074
+ // onAppAction ask by changing the data rather than replying in the chat).
1075
+ const { doc, version: stateVersion } = await client.apps.state(identity.apiKey, installation.id);
1076
+ try {
1077
+ await client.apps.setState(identity.apiKey, installation.id, { ...doc, done: true }, stateVersion);
1078
+ } catch (err) {
1079
+ if (err instanceof AppStateConflictError) {
1080
+ // err.doc / err.version are the CURRENT doc -- merge your change onto
1081
+ // it and retry with err.version, rather than re-fetching first.
1082
+ } else {
1083
+ throw err;
1084
+ }
1085
+ }
1086
+ ```
1087
+
1088
+ `client.apps`:
1089
+
1090
+ | Method | Calls | Notes |
1091
+ |---|---|---|
1092
+ | `create(apiKey, {name, description?, html, forId?, chatId?, actions?})` | `POST /api/v1/apps` | `forId` builds it for someone else (they must share a chat with you, else 422); `chatId` also installs it there, same transaction; `actions` (`AppQuickAction[]`) is version 1's quick-tap chips. Returns `{app, version, installation?}`. |
1093
+ | `newVersion(apiKey, appId, html, actions?)` | `POST /api/v1/apps/:id/versions` | Owner or builder only. `actions` replace the prior version's (omit to drop them, don't merge). Never changes an existing installation on its own. |
1094
+ | `get(apiKey, appId)` | `GET /api/v1/apps/:id` | Metadata, latest version, owner/builder. |
1095
+ | `install(apiKey, appId, {chatId?, copyStateFrom?})` | `POST /api/v1/apps/:id/install` | No `chatId` installs personally. A second active install of the same app in one chat 409s with the existing one. |
1096
+ | `installations(apiKey, {chatId?})` | `GET /api/v1/app_installations?chat_id=` or `/mine` | With `chatId`: that chat's installations. Without: your personal ones plus every chat one you're in. |
1097
+ | `state(apiKey, installationId)` | `GET /api/v1/app_installations/:id/state` | `{doc, version}`. |
1098
+ | `setState(apiKey, installationId, doc, ifVersion)` | `PUT /api/v1/app_installations/:id/state` | Compare-and-swap. A 409 throws `AppStateConflictError` (`.doc`/`.version` — the CURRENT state, straight off the response) instead of an ordinary `SaltApiError`. |
1099
+
1100
+ `AppQuickAction` (`{id, label, input?: {placeholder}}`) is exported from
1101
+ `client.ts`. It rides on `AppVersionRef.actions` (an app's metadata, a
1102
+ `newVersion` response) and, flattened, on `AppInstallation.actions` (the
1103
+ PINNED version's own actions, for a chat bubble or the standalone
1104
+ workspace to render without a nested lookup) — both optional, both `[]`
1105
+ when the version declared none.
1106
+
1107
+ `onAppAction(ctx)` (see **Webhook event types**, above) is how the
1108
+ installation's own agent hears about `salt.ask` calls from inside the
1109
+ running app — a person tapping "add" in a shopping-list app, say.
1110
+ `ctx.reply(text)` posts into the chat (a no-op, logged, for a personal
1111
+ installation, which has no chat); the more common answer is changing the
1112
+ shared data with `client.apps.state`/`setState` so `salt.state.subscribe`
1113
+ pushes the update to everyone with the app open, live, with no reply at
1114
+ all. Works identically under webhook and socket mode, like every other
1115
+ event this SDK dispatches.
1116
+
1117
+ ## Device control: one controller, a queue, the person first
1118
+
1119
+ A device (a computer running the Salt desktop app) has at most ONE controlling
1120
+ agent at a time. `createDeviceClient` (agent side) and `createDeviceHost`
1121
+ (device side) implement the lock; the server half is in
1122
+ `salt-api/docs/DEVICE_PROTOCOL.md`.
1123
+
1124
+ ```ts
1125
+ const device = createDeviceClient(httpDeviceAgentTransport({ host, apiKey }), { deviceId });
1126
+ device.onState((s) => console.log(s)); // queued(position) | requested | active | paused(reason) | resumed | ended(reason)
1127
+ await device.open(); // waits through queued/requested on deliveries, never polls
1128
+ await device.open({ wait: false }); // or: return at once with the queued state
1129
+ await device.open({ intent: "Download the three invoices and save them to Documents" }); // see Intent
1130
+ await device.click({ x: 10, y: 20 }); // one command at a time, however many callers
1131
+ ```
1132
+
1133
+ - **Intent.** `open({ intent })` says in one line what you are about to do (at most 140
1134
+ characters after the server cleans control characters and whitespace). Salt stores it on the
1135
+ session and the person sees it as your own words in the Salt Device panel, beside who is
1136
+ asking; it is never checked and never changes what you may do. Longer is refused with a 422
1137
+ and the server's sentence is the thrown error (`code: "intent_too_long"`). Optional: omit it
1138
+ and the panel shows no line.
1139
+ - **Queue.** `open()` against a device another agent controls is answered 202 `queued`;
1140
+ it resolves when a `device_session` delivery (`promoted`, then approval) makes the
1141
+ session `active`. Feed deliveries in with `device.handleDelivery("device_session", body)`
1142
+ (from `onDeviceDelivery`) and lane results with `device.handleLaneMessage(plaintext)`.
1143
+ - **Reattach.** If this agent already has a live session (409 `session_live`) `open()`
1144
+ calls `POST /device_sessions/:id/reattach` and continues at `last_seq + 1`.
1145
+ - **One command in flight.** Commands carry `id`, `seq` and `exp` (epoch ms, now + 30 s).
1146
+ The client queues concurrent calls and sends them one at a time; a resend reuses the
1147
+ `id`; `out_of_order` (with `expected_seq`) and `expired` are resent once; a client
1148
+ timeout sends `cancel {id}`.
1149
+ - **Paused.** While the person is using the machine or a secure field needs them, the
1150
+ session is paused: commands reject at once with `DevicePausedError` (`.reason`) and
1151
+ nothing is sent. Wait for the `resumed` state.
1152
+ - **Whole tasks.** With a `device.task` grant, `await device.task("Open saltapp.ai in
1153
+ Firefox", { doneWhen, maxSteps, timeoutS, onProgress, signal })` hands the computer ONE job
1154
+ and resolves a checked `TaskResult` (words and numbers only; command output and screenshots
1155
+ never leave the computer). The host side is an optional `handlers.task(args, ctx)`. See
1156
+ "Hand a whole task to a computer" in `AGENTS.md`.
1157
+
1158
+ Device side:
1159
+
1160
+ ```ts
1161
+ const host = createDeviceHost(snapshot, handlers, httpDeviceHostTransport({ host, apiKey }), callbacks);
1162
+ host.start();
1163
+ await host.pause("person_active"); // refuses commands with `paused`; tells Salt (POST .../pause)
1164
+ await host.resume(); // POST .../resume
1165
+ ```
1166
+
1167
+ The host runs one command at a time per session, keeps the last 64 results by `id`
1168
+ (a repeated `id` returns the stored result without executing), refuses `expired`
1169
+ commands, and holds ONE device-wide mutex around `click/type/key/scroll/focus_app`
1170
+ shared by every host in the process (`sharedDeviceLock`, or pass `{deviceLock}`).
1171
+ Constants (`COMMAND_TTL_MS`, `RESULT_CACHE_SIZE`, `MUTATING_OPS`) live in `device/protocol.ts`.
261
1172
 
262
1173
  ## Reference implementations
263
1174