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.
- package/LICENSE +21 -0
- package/README.md +941 -30
- package/dist/actions.d.ts +21 -5
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +673 -54
- package/dist/actions.js.map +1 -1
- package/dist/ask.d.ts +83 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +215 -0
- package/dist/ask.js.map +1 -0
- package/dist/chatPrivacy.d.ts +34 -0
- package/dist/chatPrivacy.d.ts.map +1 -0
- package/dist/chatPrivacy.js +60 -0
- package/dist/chatPrivacy.js.map +1 -0
- package/dist/client.d.ts +2783 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1200 -143
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +16 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -5
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +62 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +121 -0
- package/dist/crypto.js.map +1 -1
- package/dist/delegations.d.ts +25 -8
- package/dist/delegations.d.ts.map +1 -1
- package/dist/delegations.js +67 -11
- package/dist/delegations.js.map +1 -1
- package/dist/device/client.d.ts +161 -0
- package/dist/device/client.d.ts.map +1 -0
- package/dist/device/client.js +601 -0
- package/dist/device/client.js.map +1 -0
- package/dist/device/host.d.ts +222 -0
- package/dist/device/host.d.ts.map +1 -0
- package/dist/device/host.js +891 -0
- package/dist/device/host.js.map +1 -0
- package/dist/device/http.d.ts +12 -0
- package/dist/device/http.d.ts.map +1 -0
- package/dist/device/http.js +124 -0
- package/dist/device/http.js.map +1 -0
- package/dist/device/index.d.ts +5 -0
- package/dist/device/index.d.ts.map +1 -0
- package/dist/device/index.js +27 -0
- package/dist/device/index.js.map +1 -0
- package/dist/device/protocol.d.ts +312 -0
- package/dist/device/protocol.d.ts.map +1 -0
- package/dist/device/protocol.js +435 -0
- package/dist/device/protocol.js.map +1 -0
- package/dist/identities.d.ts +9 -2
- package/dist/identities.d.ts.map +1 -1
- package/dist/identities.js +33 -3
- package/dist/identities.js.map +1 -1
- package/dist/identity.d.ts +155 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +196 -0
- package/dist/identity.js.map +1 -0
- package/dist/identityShare.d.ts +129 -0
- package/dist/identityShare.d.ts.map +1 -0
- package/dist/identityShare.js +308 -0
- package/dist/identityShare.js.map +1 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +25 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +60 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/recovery.d.ts +51 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +59 -0
- package/dist/recovery.js.map +1 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +65 -0
- package/dist/register.js.map +1 -0
- package/dist/scriptJob.d.ts +92 -0
- package/dist/scriptJob.d.ts.map +1 -0
- package/dist/scriptJob.js +270 -0
- package/dist/scriptJob.js.map +1 -0
- package/dist/sessions.d.ts +131 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +240 -0
- package/dist/sessions.js.map +1 -0
- package/dist/socket.d.ts +115 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +1038 -0
- package/dist/socket.js.map +1 -0
- package/dist/webhook.d.ts +613 -24
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1384 -83
- package/dist/webhook.js.map +1 -1
- package/dist/work.d.ts +57 -0
- package/dist/work.d.ts.map +1 -0
- package/dist/work.js +213 -0
- package/dist/work.js.map +1 -0
- 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://
|
|
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://
|
|
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
|
-
|
|
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:
|
|
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
|
-
###
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
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,
|
|
153
|
-
|
|
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
|
-
| `
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
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
|
|
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
|
-
`
|
|
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
|
|
235
|
-
|
|
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
|
-
`
|
|
242
|
-
|
|
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
|
-
`
|
|
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`,
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|