salt-agent-sdk 0.1.0 → 0.19.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 +3123 -35
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +1267 -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 +627 -28
  96. package/dist/webhook.d.ts.map +1 -1
  97. package/dist/webhook.js +1388 -84
  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/dist/webhook.d.ts CHANGED
@@ -1,20 +1,51 @@
1
1
  import { type Express } from "express";
2
- import type { SaltClient } from "./client";
2
+ import type { AskOptions, AskResult } from "./ask.js";
3
+ import type { Mandate, MandateExercise, ReactionSummary, SaltClient } from "./client";
4
+ import * as identityShare from "./identityShare.js";
5
+ import type { IdentityAskHandler, IdentitySharedHandler } from "./identityShare.js";
3
6
  import type { AgentIdentity, IdentityStore } from "./identities";
7
+ import { type SaltId } from "./ids.js";
8
+ import type { Session, SessionStore } from "./sessions";
9
+ /** See MessageContext.deliveredBecause / SessionTurn.deliveredBecause. */
10
+ export type DeliveredBecause = "mention" | "reply" | "keyword" | "all" | "team_lead";
11
+ /** The original post a reply answers, exactly as salt-api sent it on the delivery
12
+ * (`message.parent`) -- present only for a profile-room reply, where the post
13
+ * is public plaintext. Never built locally. See MessageContext.parent. */
14
+ export interface ReplyParent {
15
+ id: SaltId;
16
+ author: unknown;
17
+ excerpt: string;
18
+ }
4
19
  /** The exact marker a hand-off briefing must start with, so the incoming
5
20
  * agent's polling loop (below) can detect it arrived. Consumers building
6
21
  * the outgoing briefing prompt must instruct their model to emit this
7
22
  * exact line first. */
8
23
  export declare const HANDOFF_BRIEFING_MARKER = "[[SALT-HANDOFF-BRIEFING]]";
9
24
  export interface RawChatMeta {
10
- active_agent_id?: number | string;
11
- mediator_agent_id?: number | string;
12
- coaching_for_chat_id?: number | string;
25
+ active_agent_id?: SaltId;
26
+ mediator_agent_id?: SaltId;
27
+ coaching_for_chat_id?: SaltId;
28
+ /** True when this coaching chat is a PRIVATE advisor lane rather than the
29
+ * mutual Mediator lane. A private advisor is not a member of the shared
30
+ * chat and therefore cannot decrypt it -- this flag stops us asking. */
31
+ private_lane?: boolean;
32
+ /** The kind of lane this chat is, when it is one. "consult" for a lane consult_agent opened -- see webhook.ts's CONSULT_RUNAWAY_LIMIT and actions.ts's request_floor. Absent (or any other value) for an ordinary chat or another lane kind (Mediator coaching, a translator, a work.ts report lane). */
33
+ lane_kind?: string;
13
34
  mode?: "auto" | "manual";
14
35
  [key: string]: unknown;
15
36
  }
37
+ /** What `onDeviceLaneMessage` receives. */
38
+ export interface DeviceLaneMessage {
39
+ identity: AgentIdentity;
40
+ chatId: SaltId;
41
+ senderId: SaltId;
42
+ sender: RawSender;
43
+ /** The decrypted lane message (a device-protocol line). */
44
+ text: string;
45
+ chatMeta?: RawChatMeta;
46
+ }
16
47
  export interface RawSender {
17
- id: number | string;
48
+ id: SaltId;
18
49
  display_name?: string;
19
50
  account_type?: "User" | "Agent";
20
51
  [key: string]: unknown;
@@ -23,44 +54,180 @@ export interface DecryptedAttachment {
23
54
  filename: string;
24
55
  contentType: string;
25
56
  size: number;
26
- /** Present only when contentType starts with "image/" -- the SDK doesn't
27
- * know how to represent other file types beyond a note in `unsupportedNote`. */
57
+ /** The decrypted file bytes -- present for every content type once the
58
+ * declared size is within pgp.MAX_ATTACHMENT_BYTES (images, PDFs, and
59
+ * anything else); absent only when `unsupportedNote` explains why
60
+ * (oversized, or the decrypt/download itself failed). */
28
61
  data?: Buffer;
62
+ /** UTF-8 decoded `data`, set only for text-shaped content: text/*, JSON,
63
+ * CSV, or Markdown (see webhook.ts's isTextLikeAttachment). Not set for
64
+ * images, audio/video, or PDF -- a PDF's bytes are handed through as
65
+ * `data` for a consumer to extract text from itself (e.g. Python's
66
+ * pypdf); the SDK has no pure-JS PDF parser in its dependency tree to
67
+ * do that extraction here. */
68
+ text?: string;
29
69
  unsupportedNote?: string;
30
70
  }
71
+ /** Options for `ctx.reply(text, opts)`. */
72
+ export interface ReplyOptions {
73
+ /** User ids to @-mention. Salt only sees ciphertext, so an "@handle" in the body notifies nobody by itself; these ids
74
+ * are what wake a member (an agent in a group is only woken when mentioned). Salt keeps only ids of chat members.
75
+ * When given, they replace the automatic "@sender" addressing and the text is posted exactly as written, so write
76
+ * the "@handle" yourself. */
77
+ mentions?: SaltId[];
78
+ }
31
79
  export interface MessageContext {
32
80
  identity: AgentIdentity;
33
- chatId: number;
34
- senderId: number;
81
+ chatId: SaltId;
82
+ senderId: SaltId;
35
83
  sender: RawSender;
36
- /** Delegation-marker-stripped plaintext (see delegations.parseIncoming). */
84
+ /** Delegation- and consult-marker-stripped plaintext (see delegations.parseIncoming / delegations.stripConsultMarker). */
37
85
  text: string;
86
+ /** False for an open-room message (salt-api's `message.encrypted === false`
87
+ * on the delivery) -- `text` came straight off the wire with no PGP
88
+ * decrypt attempted, so a reply into this chat should go through
89
+ * `client.postPlainMessage`, never `reply()`'s normal encrypt-for-every-
90
+ * member path. True (the default) for an ordinary end-to-end encrypted
91
+ * chat. See client.ts's postPlainMessage/setChatSubscription. */
92
+ encrypted: boolean;
93
+ /** Why THIS delivery reached this identity, on an open room only --
94
+ * salt-api's `message.delivered_because` (present only alongside
95
+ * `encrypted: false`; undefined for an ordinary encrypted chat, where
96
+ * every member always gets every message and the question doesn't
97
+ * apply). `"mention"`/`"reply"` are the two ways an `"addressed"`
98
+ * subscription (client.ts's setChatSubscription) can trigger; `"keyword"`
99
+ * is one of that subscription's own `keywords` appearing in the message;
100
+ * `"all"` means the subscription just wants everything. `"team_lead"` is a
101
+ * team room (not an open room): the team's lead is woken by a message with
102
+ * no @mention, so it can answer or route it (see AGENTS.md, "Teams"). */
103
+ deliveredBecause?: DeliveredBecause;
104
+ /** The id of the message this one replies to (salt-api's
105
+ * `message.reply_to_message_id`); undefined for a top-level message. */
106
+ replyTo?: SaltId;
107
+ /** The original post this message replies to, when salt-api sent it
108
+ * (`message.parent`: profile-room replies only). Undefined otherwise --
109
+ * an agent should treat absence as "origin not provided", not "none". */
110
+ parent?: ReplyParent;
38
111
  /** Delegation hop depth this message arrived at; pass through to any further delegate call. */
39
112
  delegationDepth: number;
40
113
  chatMeta?: RawChatMeta;
114
+ /** The shared chat this message's conversation ultimately serves --
115
+ * `chatId` itself for an ordinary chat, or `chatMeta.coaching_for_chat_id`
116
+ * when `chatId` is a lane (a sidechain, Mediator coaching, a consult).
117
+ * Lets a handler act on "the room" even while replying inside a lane
118
+ * (e.g. request_floor's hand-off target). */
119
+ roomId: SaltId;
41
120
  /** Set when this identity is the configured Mediator privately coaching
42
121
  * someone about a shared chat it also observes -- the generic decrypted
43
122
  * transcript of that shared chat (not yet wrapped in any prompt framing). */
44
123
  mediatorSharedContext?: string;
45
- /** Set when the inbound message was an Attachment with decryptable metadata. */
124
+ /** Set when the inbound message was an Attachment with decryptable
125
+ * metadata -- images, PDFs, and any other file type all carry decrypted
126
+ * `data` bytes now (bounded by pgp.MAX_ATTACHMENT_BYTES); text-shaped
127
+ * files (text/*, JSON, CSV, Markdown) also carry a decoded `text` field.
128
+ * See DecryptedAttachment. */
46
129
  attachment?: DecryptedAttachment;
130
+ /** This identity's memory of `chatId`: recent turns and a short note,
131
+ * loaded (or rebuilt from chat history, on a cold start) before this
132
+ * handler runs and persisted after it returns. A plain mutable object --
133
+ * write to `session.note` directly (e.g. session.note.goal, or push onto
134
+ * session.note.consulted) to have it survive a restart and ride along a
135
+ * hand-off. Ignore it entirely and nothing changes. */
136
+ session: Session;
47
137
  /** Encrypts `text` for every current chat member (+ this identity's own
48
138
  * copy), posts it, drains this reply's delegation trail onto it, and
49
139
  * emits an agent_reply_sent metric. Handles a typing-indicator heartbeat
50
140
  * for the duration of the call automatically. */
51
- reply(text: string): Promise<void>;
141
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
142
+ /** The id of the message being handled (the delivery's `message_id`). */
143
+ messageId: SaltId;
144
+ /** React to THIS message with one emoji; resolves with the server's reactions summary.
145
+ * Use sparingly -- the owner's rule: "not all the time, just when they choose", and only
146
+ * "if it relevantly complements the chat in a friendly way" (acknowledge thanks, mark a
147
+ * request done, celebrate good news). Never instead of answering a question, never on
148
+ * every message, never on your own, at most one per message. Calling it again with the
149
+ * same emoji REMOVES your reaction (the API is a toggle). A rejection (not a single
150
+ * emoji, over the 12 cap) throws a SaltApiError with the server's sentence. */
151
+ react(emoji: string): Promise<ReactionSummary>;
152
+ /** Asks a quick inline question (K3) -- a card with one button per
153
+ * option, plus (by default, when there are no options) an invitation to
154
+ * type a free-form reply -- and resolves with whichever answer arrives
155
+ * first. Works identically under webhook and socket mode. See ask.ts. */
156
+ ask(question: string, opts?: AskOptions): Promise<AskResult>;
157
+ /** Sugar for `ask` with Yes/No buttons. See ask.ts's approve(). */
158
+ approve(summary: string, opts?: {
159
+ timeoutMs?: number;
160
+ }): Promise<{
161
+ approved: boolean;
162
+ by: SaltId;
163
+ via: "button" | "message";
164
+ }>;
165
+ /** Shares `keys` from this identity's own card into `chatId` -- one signed SLICE to every non-observer member (see identityShare.ts's share()). */
166
+ shareIdentity(keys: string[], opts?: identityShare.ShareOptions): Promise<identityShare.ShareResult>;
167
+ }
168
+ /** An incoming `[[SALT-WORK-CANCEL id=...]]` (work.ts) -- the person
169
+ * cancelling a `scheduled` report, sent as a whole message into the same
170
+ * lane the report came from. Always intercepted before `onMessage`, same
171
+ * as a SALT-IDENTITY-SLICE/DECLINE/REVOKE (there's no "ordinary text"
172
+ * reading of a bare cancel marker). See WebhookServerOptions.onWorkCancel. */
173
+ export interface WorkCancelContext {
174
+ identity: AgentIdentity;
175
+ /** The work id named by the marker. */
176
+ workId: string;
177
+ /** The chat the cancelled work came from -- matches work.ts's
178
+ * WorkTarget.chatId (the room, not the lane the cancel arrived in). */
179
+ chatId: SaltId;
180
+ /** The lane chat this cancel message actually arrived in -- the same
181
+ * sidechain the report it's cancelling was posted into. Equal to
182
+ * `chatId` when, unusually, the cancel didn't arrive inside a lane. */
183
+ laneId: SaltId;
184
+ /** Whoever sent the cancel -- the person the work was for. */
185
+ sender: RawSender;
52
186
  }
53
187
  export interface CardInteractionContext {
54
188
  identity: AgentIdentity;
55
- chatId: number;
56
- cardId: number | string;
189
+ chatId: SaltId;
190
+ cardId: SaltId;
57
191
  actionId: string;
58
192
  user: RawSender;
59
193
  blocks: unknown;
194
+ /** Every `input` block's current value at tap time, keyed by `block_id`
195
+ * (NEW) -- `{}` when the card has no input blocks or the tapped button
196
+ * ignored them (pay/handoff buttons always do). See client.ts's
197
+ * InputCardBlock / CardInteraction.values. */
198
+ values: Record<string, string>;
199
+ /** This identity's memory of `chatId` (see MessageContext.session). Loaded
200
+ * before this handler runs, same as everywhere else -- but there's no
201
+ * reply() here (a card update IS the response), so nothing is appended
202
+ * or persisted afterward. Read it; write session.note if you want, but
203
+ * know it won't be saved from this call. */
204
+ session: Session;
205
+ }
206
+ export interface AppActionContext {
207
+ identity: AgentIdentity;
208
+ installationId: SaltId;
209
+ /** The app's identity and the PINNED version number this installation was running at ask time -- not necessarily the app's latest published version. */
210
+ app: {
211
+ id: SaltId;
212
+ name: string;
213
+ version: number;
214
+ };
215
+ /** Null for a personal ("Your apps") installation -- `reply()` then has nothing to post into and fails soft (logged), the same way `salt.post` on the bridge side rejects with "Not in a chat." */
216
+ chatId: SaltId | null;
217
+ /** The ask's own text (1..2000, salt-api-enforced). */
218
+ text: string;
219
+ /** The ask's optional structured payload (≤ 16 KB, salt-api-enforced). */
220
+ data?: Record<string, unknown>;
221
+ /** The shared state's `version` at ask time -- a snapshot, not a lock; read fresh with `client.apps.state` (or just `setState`'s own conflict) before writing. */
222
+ stateVersion: number;
223
+ /** Whoever tapped `salt.ask` inside the app. */
224
+ sender: RawSender;
225
+ /** Encrypts `text` for every current chat member (+ this identity's own copy) and posts it -- same closure every other context type gets. A no-op (logged) on a personal installation, which has no chat to post into. */
226
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
60
227
  }
61
228
  export interface InvoicePaidContext {
62
229
  identity: AgentIdentity;
63
- chatId: number;
230
+ chatId: SaltId;
64
231
  buyer: RawSender;
65
232
  lineItems: Array<{
66
233
  name: string;
@@ -70,23 +237,289 @@ export interface InvoicePaidContext {
70
237
  amount: string | number;
71
238
  /** True for a "Credits top-up" invoice -- no delivery owed, the credit already landed server-side. */
72
239
  isTopUp: boolean;
73
- transferRequestId: number | string;
74
- reply(text: string): Promise<void>;
240
+ transferRequestId: SaltId;
241
+ /** This identity's memory of `chatId` (see MessageContext.session). Whatever you reply() with here is appended as an assistant turn and persisted, same as onMessage. */
242
+ session: Session;
243
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
244
+ /** See MessageContext.ask. */
245
+ ask(question: string, opts?: AskOptions): Promise<AskResult>;
246
+ /** See MessageContext.approve. */
247
+ approve(summary: string, opts?: {
248
+ timeoutMs?: number;
249
+ }): Promise<{
250
+ approved: boolean;
251
+ by: SaltId;
252
+ via: "button" | "message";
253
+ }>;
254
+ /** See MessageContext.shareIdentity. */
255
+ shareIdentity(keys: string[], opts?: identityShare.ShareOptions): Promise<identityShare.ShareResult>;
256
+ }
257
+ export interface ChatOpenedChat {
258
+ id: SaltId;
259
+ name?: string;
260
+ public?: boolean;
261
+ managed?: boolean;
262
+ open_invite?: boolean;
263
+ mode?: "auto" | "manual";
264
+ active_agent_id?: SaltId;
265
+ mediator_agent_id?: SaltId;
266
+ coaching_for_chat_id?: SaltId;
267
+ private_lane?: boolean;
268
+ /** False for an open room (no end-to-end encryption). Absent on an older
269
+ * server that predates open rooms, which always means an ordinary
270
+ * encrypted chat -- see ChatOpenedContext.encrypted for the resolved,
271
+ * defaulted value this SDK actually exposes. */
272
+ encrypted?: boolean;
273
+ [key: string]: unknown;
274
+ }
275
+ export interface ChatOpenedMember extends RawSender {
276
+ observer?: boolean;
277
+ }
278
+ export interface ChatOpenedContext {
279
+ identity: AgentIdentity;
280
+ chatId: SaltId;
281
+ chat: ChatOpenedChat;
282
+ /** False for an open room -- `chat.encrypted === false` on the payload,
283
+ * defaulted to true when the field is absent (an older server, or an
284
+ * ordinary encrypted chat, where the question doesn't apply). `reply()`
285
+ * below always PGP-encrypts regardless -- greet an open room plainly
286
+ * with `client.postPlainMessage(identity.apiKey, chatId, text)` instead,
287
+ * same convention as MessageContext.encrypted. */
288
+ encrypted: boolean;
289
+ /** The person (or agent -- see `openedBy.account_type`) who newly opened
290
+ * this chat: a fresh 1:1, a new group that includes this identity, or an
291
+ * add-to-an-existing-group. */
292
+ openedBy: RawSender;
293
+ /** Every current member, including this identity's own account. */
294
+ members: ChatOpenedMember[];
295
+ openedAt: string;
296
+ /** This identity's memory of `chatId` (see MessageContext.session). Whatever you reply() with here (e.g. a greeting) is appended as an assistant turn and persisted, same as onMessage. */
297
+ session: Session;
298
+ /** Encrypts `text` for every current chat member (+ this identity's own
299
+ * copy) and posts it -- the same closure every other context type gets. */
300
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
301
+ /** See MessageContext.ask. */
302
+ ask(question: string, opts?: AskOptions): Promise<AskResult>;
303
+ /** See MessageContext.approve. */
304
+ approve(summary: string, opts?: {
305
+ timeoutMs?: number;
306
+ }): Promise<{
307
+ approved: boolean;
308
+ by: SaltId;
309
+ via: "button" | "message";
310
+ }>;
311
+ /** See MessageContext.shareIdentity. */
312
+ shareIdentity(keys: string[], opts?: identityShare.ShareOptions): Promise<identityShare.ShareResult>;
75
313
  }
76
314
  export interface HandoffConfirmedContext {
77
315
  identity: AgentIdentity;
78
- chatId: number;
316
+ chatId: SaltId;
79
317
  reason?: string;
80
- reply(text: string): Promise<void>;
318
+ /** Set only when this hand-off was triggered automatically by the
319
+ * consulted agent's request_floor call rather than a model-chosen
320
+ * hand_off_to_agent/hand_back_to_concierge: the consult lane's decrypted
321
+ * transcript so far (capped), for the briefing to draw on. */
322
+ consultTranscript?: string;
323
+ /** This identity's memory of `chatId` (see MessageContext.session): the
324
+ * tail and note it built up while it was the active agent here, so a
325
+ * briefing composed from it isn't starting cold. Whatever you reply()
326
+ * with (the briefing) is appended as an assistant turn and persisted --
327
+ * moot once you've handed off, but there if you're ever handed this
328
+ * chat again later. */
329
+ session: Session;
330
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
331
+ /** See MessageContext.ask. */
332
+ ask(question: string, opts?: AskOptions): Promise<AskResult>;
333
+ /** See MessageContext.approve. */
334
+ approve(summary: string, opts?: {
335
+ timeoutMs?: number;
336
+ }): Promise<{
337
+ approved: boolean;
338
+ by: SaltId;
339
+ via: "button" | "message";
340
+ }>;
341
+ /** See MessageContext.shareIdentity. */
342
+ shareIdentity(keys: string[], opts?: identityShare.ShareOptions): Promise<identityShare.ShareResult>;
81
343
  }
82
344
  export interface HandoffReceivedContext {
83
345
  identity: AgentIdentity;
84
- chatId: number;
346
+ chatId: SaltId;
85
347
  reason?: string;
86
348
  /** The shared chat's decrypted transcript, polled for up to ~20s until
87
349
  * the outgoing agent's HANDOFF_BRIEFING_MARKER shows up (or timeout). */
88
350
  context: string;
89
- reply(text: string): Promise<void>;
351
+ /** This identity's memory of `chatId`, seeded from the outgoing agent's
352
+ * session note (see sessions.ts's SESSION_NOTE_MARKER) when the briefing
353
+ * carried one. Whatever you reply() with here (your introduction) is
354
+ * appended as an assistant turn and persisted, so it's already in
355
+ * transcriptTail by the time the next onMessage call for this chat runs. */
356
+ session: Session;
357
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
358
+ /** See MessageContext.ask. */
359
+ ask(question: string, opts?: AskOptions): Promise<AskResult>;
360
+ /** See MessageContext.approve. */
361
+ approve(summary: string, opts?: {
362
+ timeoutMs?: number;
363
+ }): Promise<{
364
+ approved: boolean;
365
+ by: SaltId;
366
+ via: "button" | "message";
367
+ }>;
368
+ /** See MessageContext.shareIdentity. */
369
+ shareIdentity(keys: string[], opts?: identityShare.ShareOptions): Promise<identityShare.ShareResult>;
370
+ }
371
+ export interface MandateOfferedContext {
372
+ identity: AgentIdentity;
373
+ mandate: Mandate;
374
+ /** Accepts THIS mandate (`client.mandates.accept`) -- binds this identity's current key
375
+ * fingerprint and moves it to `active`. Call it, or don't: an offer left untouched just stays
376
+ * `proposed` until someone accepts it or the 7-day proposal window lapses. */
377
+ accept(): Promise<Mandate>;
378
+ }
379
+ export interface MandateActivatedContext {
380
+ identity: AgentIdentity;
381
+ mandate: Mandate;
382
+ }
383
+ export interface MandatePausedContext {
384
+ identity: AgentIdentity;
385
+ mandate: Mandate;
386
+ }
387
+ export interface MandateRevokedContext {
388
+ identity: AgentIdentity;
389
+ mandate: Mandate;
390
+ }
391
+ export interface ApprovalRequestedContext {
392
+ identity: AgentIdentity;
393
+ exercise: MandateExercise;
394
+ /** Approve or deny the ask (`client.mandates.decide`). Fires only when THIS identity is the
395
+ * mandate's principal (e.g. a governance mandate's `money.pay` ask on an owned agent's own
396
+ * account) -- the delegate that made the original call gets `onApprovalDecided` instead, once
397
+ * this resolves it one way or the other. */
398
+ decide(decision: "approve" | "deny", note?: string): Promise<MandateExercise>;
399
+ }
400
+ export interface ApprovalDecidedContext {
401
+ identity: AgentIdentity;
402
+ /** Already decided (`exercise.decision` is `"approved"` or `"denied"`) -- informational, so the
403
+ * delegate that made the original ask-mode call can resume (or give up on) whatever it was
404
+ * doing. */
405
+ exercise: MandateExercise;
406
+ }
407
+ /** The `end_reason` of a device session. `lost_contact` is the one this SDK names: the computer stopped answering for 15 s mid-session. Any other value is passed through verbatim. */
408
+ export declare const DEVICE_LOST_CONTACT = "lost_contact";
409
+ /** Who a device-lane message must be encrypted for: `{ids}` (account ids, sorted, the caller excluded) or `{open: true}`. */
410
+ export interface DeviceChatBlock {
411
+ id: SaltId;
412
+ readers: {
413
+ ids?: SaltId[];
414
+ open?: boolean;
415
+ };
416
+ }
417
+ /** The body of a `device_session_ended` delivery: the session row plus `type`/`event`/`session_id`. Every field beyond `type`, `session_id` and `device_id` may be absent on an older server. */
418
+ export interface DeviceSessionEndedBody {
419
+ type: "device_session_ended";
420
+ event?: "ended";
421
+ session_id: SaltId;
422
+ id?: SaltId;
423
+ agent_id?: SaltId;
424
+ device_id: SaltId;
425
+ mandate_id?: SaltId | null;
426
+ /** The device LANE (where commands and results travelled), not the person's 1:1. */
427
+ chat_id?: SaltId | null;
428
+ status?: string;
429
+ intent?: string | null;
430
+ end_reason?: string | null;
431
+ requested_at?: string | null;
432
+ started_at?: string | null;
433
+ ended_at?: string | null;
434
+ chat?: DeviceChatBlock;
435
+ [key: string]: unknown;
436
+ }
437
+ /** The body of a `device_back` delivery: sent once per session that ended `lost_contact`, within 24 h, while the mandate is still active, the first time the computer is seen alive again. */
438
+ export interface DeviceBackBody {
439
+ type: "device_back";
440
+ device_id: SaltId;
441
+ device_name: string;
442
+ previous_session_id: SaltId;
443
+ /** When Salt ended that session for lost contact. */
444
+ offline_at: string | null;
445
+ /** When the computer was seen alive again. */
446
+ back_at: string;
447
+ /** The person's 1:1 with this agent; null when they have none. */
448
+ chat_id: SaltId | null;
449
+ chat?: DeviceChatBlock;
450
+ [key: string]: unknown;
451
+ }
452
+ /**
453
+ * A device session this identity ran just ended. Fires for EVERY end_reason;
454
+ * check `lostContact` (end_reason `"lost_contact"`) for the one that needs
455
+ * words. There is no `reply()` on purpose: `laneChatId` is the device lane
456
+ * the work travelled in, which the person does not read. Say it in the chat
457
+ * the work came from (remember it when you open the session) or their 1:1.
458
+ * Etiquette is in AGENTS.md ("A computer going quiet"): say it once per
459
+ * `sessionId`, plainly, and say that nothing else was sent. Never claim an
460
+ * action landed unless a device tool returned it.
461
+ */
462
+ export interface DeviceSessionEndedContext {
463
+ identity: AgentIdentity;
464
+ sessionId: SaltId;
465
+ deviceId: SaltId;
466
+ mandateId: SaltId | null;
467
+ /** The device lane, NOT a chat to answer the person in. */
468
+ laneChatId: SaltId | null;
469
+ endReason: string | null;
470
+ /** `endReason === "lost_contact"`: the computer stopped answering for 15 s mid-session. */
471
+ lostContact: boolean;
472
+ intent: string | null;
473
+ endedAt: string | null;
474
+ /** The delivery verbatim. */
475
+ body: DeviceSessionEndedBody;
476
+ }
477
+ /**
478
+ * The computer that went quiet during one of this identity's sessions is
479
+ * answering again. Grants nothing: open a NEW session to continue. Offer to
480
+ * finish, and look at the screen before assuming the last step landed. Fires
481
+ * once per lost session; say it once.
482
+ */
483
+ export interface DeviceBackContext {
484
+ identity: AgentIdentity;
485
+ deviceId: SaltId;
486
+ deviceName: string;
487
+ /** The session that ended `lost_contact` -- also the key to say this once. */
488
+ previousSessionId: SaltId;
489
+ offlineAt: string | null;
490
+ backAt: string;
491
+ /** The person's 1:1 with this agent, or null when they have none (then `reply()` throws). */
492
+ chatId: SaltId | null;
493
+ /** This identity's memory of `chatId`; null when `chatId` is. Whatever you reply() with is appended as an assistant turn and persisted. */
494
+ session: Session | null;
495
+ /** The delivery verbatim. */
496
+ body: DeviceBackBody;
497
+ /** Encrypts `text` for the 1:1's members and posts it. Throws when `chatId` is null. */
498
+ reply(text: string, opts?: ReplyOptions): Promise<void>;
499
+ }
500
+ /** A `describe_yourself` delivery: Salt asks this agent to write its own public description (the
501
+ * text shown on its profile, card and in the directory). Write it with `ctx.write(text)` -- that is
502
+ * `PATCH /api/v1/identity/sections {bio}` by this identity's own api-key; plain text, at most
503
+ * `maxLength` (300) characters. No chat is involved and no reply is read. */
504
+ export interface DescribeYourselfContext {
505
+ identity: AgentIdentity;
506
+ reason: "created" | "owner_changed" | "owner_asked" | "backfill" | string;
507
+ agent: {
508
+ id: SaltId;
509
+ username: string;
510
+ display_name: string;
511
+ category?: string | null;
512
+ };
513
+ /** What the owner wrote about this agent. Private to the owner and the agent; null when blank. */
514
+ ownerDescription: string | null;
515
+ /** What the public sees now (null when nothing is set). */
516
+ currentDescription: string | null;
517
+ capabilities: unknown[];
518
+ /** Salt's own instruction for how to write it. */
519
+ guidance: string;
520
+ maxLength: number;
521
+ /** Write the description (trimmed to `maxLength`); resolves once Salt has saved it. */
522
+ write(text: string): Promise<void>;
90
523
  }
91
524
  export interface Logger {
92
525
  info(msg: string): void;
@@ -96,29 +529,195 @@ export interface WebhookServerOptions {
96
529
  client: SaltClient;
97
530
  identities: IdentityStore;
98
531
  pgpPassphrase: string;
99
- /** Required in production: must match the X-Salt-Webhook-Secret header
100
- * salt-api's jobs send. Unset (local dev) skips the check entirely --
101
- * encrypted-message webhooks fail safe regardless (forged ciphertext
102
- * never decrypts), but card_interaction/invoice_paid payloads are
103
- * plaintext and would otherwise be actable by anyone who can reach this server. */
104
- webhookSharedSecret?: string;
532
+ /** Verify the HMAC signature salt-api sends on every callback.
533
+ *
534
+ * Defaults ON. Each identity has its OWN signing key, fetched from salt-api
535
+ * with that identity's api key and cached. This replaced a single fleet-wide
536
+ * WEBHOOK_SHARED_SECRET: because anyone could register an agent pointing at
537
+ * their own server, that secret leaked to any caller who asked for it, and a
538
+ * secret shared with the parties you authenticate against cannot prove who
539
+ * sent a request.
540
+ *
541
+ * Encrypted-message webhooks fail safe regardless (forged ciphertext never
542
+ * decrypts), but card_interaction/invoice_paid/chat_opened payloads are
543
+ * PLAINTEXT and would otherwise be actable by anyone who can reach this
544
+ * server.
545
+ *
546
+ * Set false only for local development against a dev salt-api. */
547
+ verifySignatures?: boolean;
548
+ /** Reject signatures older than this, so a captured POST can't be replayed. */
549
+ signatureToleranceSeconds?: number;
105
550
  /** This process's configured Mediator identity, if any -- enables the
106
551
  * "observe the shared chat silently, only speak in private coaching
107
552
  * chats" gate. */
108
- mediatorAgentId?: number;
553
+ mediatorAgentId?: SaltId;
554
+ /** How many messages from AGENT senders one chat may deliver before this
555
+ * process stops answering them there (the count resets whenever a person
556
+ * speaks). Default 2: the runaway guard for two hosted agents replying to
557
+ * each other. Raise it for a host whose agent legitimately talks with an
558
+ * allow-listed agent (a Claude Code channel bridge, say); a positive
559
+ * integer. Consult lanes keep their own, higher ceiling. */
560
+ maxAgentToAgentReplies?: number;
561
+ /** Where each hosted identity's per-chat session (recent turns + a short
562
+ * note) is kept. Defaults to an in-memory store (lost on restart --
563
+ * onMessage still gets a session on a cold start, rebuilt from the
564
+ * chat's own history). Pass `sessions.FileSessionStore(dir)` to persist
565
+ * across restarts; see sessions.ts for the trust boundary that store's
566
+ * own doc comment states plainly (plaintext on disk, no extra
567
+ * encryption -- the same trust level identities.json already assumes). */
568
+ sessionStore?: SessionStore;
109
569
  logger?: Logger;
110
570
  onMessage?: (ctx: MessageContext) => Promise<void> | void;
111
571
  onCardInteraction?: (ctx: CardInteractionContext) => Promise<void> | void;
572
+ /**
573
+ * Someone tapped `salt.ask` inside an app this identity is the
574
+ * installation's agent for (`POST /api/v1/app_installations/:id/ask`) --
575
+ * see AppActionContext. The usual answer is `client.apps.state`/
576
+ * `setState` (change the shared data) and/or `ctx.reply` (say something
577
+ * in the chat); `salt.ask`'s own promise on the app side resolves as soon
578
+ * as the webhook is accepted, regardless of what this handler does.
579
+ */
580
+ onAppAction?: (ctx: AppActionContext) => Promise<void> | void;
112
581
  onInvoicePaid?: (ctx: InvoicePaidContext) => Promise<void> | void;
582
+ onChatOpened?: (ctx: ChatOpenedContext) => Promise<void> | void;
113
583
  onHandoffConfirmed?: (ctx: HandoffConfirmedContext) => Promise<void> | void;
114
584
  onHandoffReceived?: (ctx: HandoffReceivedContext) => Promise<void> | void;
585
+ /** A mandate was offered to this identity and is waiting to be accepted. See
586
+ * MandateOfferedContext -- call `ctx.accept()` to accept it. */
587
+ onMandateOffered?: (ctx: MandateOfferedContext) => Promise<void> | void;
588
+ /** A mandate this identity is party to (either side) just became active. */
589
+ onMandateActivated?: (ctx: MandateActivatedContext) => Promise<void> | void;
590
+ /** A mandate this identity is party to was paused (e.g. the grantee's key changed). */
591
+ onMandatePaused?: (ctx: MandatePausedContext) => Promise<void> | void;
592
+ /** A mandate this identity is party to was revoked. */
593
+ onMandateRevoked?: (ctx: MandateRevokedContext) => Promise<void> | void;
594
+ /** This identity is the PRINCIPAL of an ask-mode call someone acting for it just made, and it's
595
+ * waiting on a decision. See ApprovalRequestedContext -- call `ctx.decide("approve"|"deny")`. */
596
+ onApprovalRequested?: (ctx: ApprovalRequestedContext) => Promise<void> | void;
597
+ /** This identity made the original ask-mode call and its exercise was just decided. */
598
+ onApprovalDecided?: (ctx: ApprovalDecidedContext) => Promise<void> | void;
599
+ /** Salt asked this agent to write its own public description (`describe_yourself`). The default is
600
+ * a no-op: an agent that does not handle it keeps whatever description its owner gave it. See
601
+ * DescribeYourselfContext -- call `ctx.write(text)`. */
602
+ onDescribeYourself?: (ctx: DescribeYourselfContext) => Promise<void> | void;
603
+ /**
604
+ * An incoming `[[SALT-IDENTITY-ASK]]` -- someone asking this identity to
605
+ * share one or more of its own card sections. Return the subset of
606
+ * `info.keys` to share (this replies with a signed SLICE) or `null`/
607
+ * `false` to decline (this replies with a DECLINE); either way the ask
608
+ * is intercepted and never reaches `onMessage`. Leave this unset and an
609
+ * incoming ask is NOT intercepted at all -- it reaches `onMessage` as
610
+ * ordinary text with just the marker line stripped, unanswered. See
611
+ * identityShare.ts.
612
+ */
613
+ onIdentityAsk?: IdentityAskHandler;
614
+ /**
615
+ * An incoming `[[SALT-IDENTITY-SLICE]]`, `[[SALT-IDENTITY-DECLINE]]` or
616
+ * `[[SALT-IDENTITY-REVOKE]]` from someone else -- always intercepted
617
+ * (never reaches `onMessage`) regardless of whether this is set. A SLICE
618
+ * is verified against the sender's own public key and, when verified,
619
+ * recorded on that chat's session (`session.identity[senderId]`, see
620
+ * sessions.ts) before this fires; a REVOKE removes any slice recorded
621
+ * under the same id first. See identityShare.ts's IdentitySharedEvent.
622
+ */
623
+ onIdentityShared?: IdentitySharedHandler;
624
+ /**
625
+ * An incoming `[[SALT-WORK-CANCEL id=...]]` (work.ts) -- always
626
+ * intercepted, never reaches `onMessage`, whether or not this is set. The
627
+ * usual answer is a fresh report under the same id with
628
+ * `status: "cancelled"` (see `createWorkReporter`'s `report`, or
629
+ * `client.postMessage` directly for a lower-level answer). Leave unset
630
+ * and a cancel is silently swallowed rather than answered -- the sender
631
+ * still never sees the marker itself.
632
+ */
633
+ onWorkCancel?: (ctx: WorkCancelContext) => Promise<void> | void;
634
+ /**
635
+ * A device session this identity ran just ended (`device_session_ended`;
636
+ * fires for every end_reason, see DeviceSessionEndedContext.lostContact).
637
+ * Not a chat message: it never reaches `onMessage`. Ignored when the
638
+ * delivery is the DEVICE's own copy (the computer hosting its own
639
+ * session), which this process only sees if it hosts the device identity.
640
+ */
641
+ onDeviceSessionEnded?: (ctx: DeviceSessionEndedContext) => Promise<void> | void;
642
+ /**
643
+ * The computer that stopped answering mid-session is back
644
+ * (`device_back`): sent once per lost session, within 24 h, while the
645
+ * mandate is active. See DeviceBackContext.
646
+ */
647
+ onDeviceBack?: (ctx: DeviceBackContext) => Promise<void> | void;
648
+ /**
649
+ * A device-control session lifecycle delivery (behind salt-api's
650
+ * `Feature :remote_control`): the body's `type` is
651
+ * `device_session_request` | `device_session_active` | `device_session_ended`,
652
+ * carrying the session metadata (ids, caps, budget, chat {id, readers}) the
653
+ * device needs to build its host. These are NOT chat messages and never reach
654
+ * `onMessage`; the device client (salt-device) passes them straight to its
655
+ * session controller. The actual command/result traffic rides the device lane
656
+ * as ordinary encrypted messages, which DO arrive through `onMessage`. The
657
+ * `type` is forwarded verbatim as the first argument. The device's own rail also
658
+ * carries `device_queue`, `device_grants_changed`, `device_owner_changed` and
659
+ * `device_ask_answered` (`{ask_id, decision, device_id, session_id}`: the person's
660
+ * phone answered a command or session ask; the answer is authenticated as the
661
+ * person by Salt). See src/device/.
662
+ */
663
+ onDeviceDelivery?: (type: string, body: Record<string, unknown>) => Promise<void> | void;
664
+ /**
665
+ * A message on a device-control lane (`chat.lane_kind === "device"`) -- the
666
+ * wire protocol between a controlling agent and a device. Set by the DEVICE
667
+ * (salt-device's sidecar): such a message is routed here right after the
668
+ * own-message check, BEFORE the mention gate, the agent-to-agent reply
669
+ * cap and session persistence (a command carries no mention, and a device
670
+ * lane would otherwise stop after two commands), and never reaches
671
+ * `onMessage`. Not awaited: a long action must not hold up the next
672
+ * delivery (the device host orders commands itself and must read `cancel`
673
+ * while one runs). The hook is the gate for WHO may command: check the
674
+ * sender against the session's agent. Unset, a device lane is handled like
675
+ * any other chat (the controlling AGENT reads results through `onMessage`).
676
+ */
677
+ onDeviceLaneMessage?: (ctx: DeviceLaneMessage) => Promise<void> | void;
115
678
  /** Extra fields to merge into the /health JSON response (e.g. which model is configured). */
116
679
  healthExtra?: () => Record<string, unknown>;
117
680
  }
681
+ /**
682
+ * Everything about the Salt protocol that is NOT "how do bytes get to this
683
+ * process" -- signature verification and the decrypt/route/dedup/reply
684
+ * logic for every webhook event kind. `createWebhookServer` below wraps
685
+ * this in an Express POST route (the only transport before K2); socket.ts's
686
+ * `createSocketClient` wraps the SAME dispatcher around a websocket push
687
+ * connection instead, so a consumer switches delivery mode with no change
688
+ * to onMessage/onCardInteraction/etc. -- see socket.ts's own doc comment.
689
+ */
690
+ export declare function createDispatcher(options: WebhookServerOptions): Dispatcher;
691
+ /** What createDispatcher returns -- the two things any transport (an
692
+ * Express POST route, socket.ts's websocket push client) needs: verify an
693
+ * envelope's signature, then hand its body to the right handler. */
694
+ export interface Dispatcher {
695
+ verifyEnvelope(agentId: string | undefined, signature: string | undefined, rawBody: string, opts?: {
696
+ toleranceSeconds?: number;
697
+ }): Promise<{
698
+ ok: true;
699
+ } | {
700
+ ok: false;
701
+ reason: string;
702
+ transient: boolean;
703
+ }>;
704
+ dispatch(body: Record<string, unknown>, headerAgentId?: SaltId): Promise<void>;
705
+ /** Settles a pending ctx.ask / delegation wait with this (already
706
+ * verified) body if it is the answer, returning true when consumed. Never
707
+ * runs a handler, so a transport whose handlers block its frame queue
708
+ * (socket.ts) can call it ahead of that queue. */
709
+ resolveAnswer(body: Record<string, unknown>, headerAgentId?: SaltId): Promise<boolean>;
710
+ }
118
711
  /**
119
712
  * Creates the Express app for a Salt agent's webhook endpoint. Mount it
120
713
  * yourself (e.g. `createWebhookServer(opts).listen(port)`, or grab `.app`
121
714
  * to compose it into a bigger server).
715
+ *
716
+ * A thin transport wrapper around createDispatcher -- everything about the
717
+ * Salt protocol itself lives there. Switching a consumer from this to
718
+ * socket.ts's createSocketClient (K2, an agent with no public URL) is the
719
+ * same `options` object with `.listen(port)` replaced by `.start()`;
720
+ * onMessage/onCardInteraction/etc. never change.
122
721
  */
123
722
  export declare function createWebhookServer(options: WebhookServerOptions): {
124
723
  app: Express;