@floomhq/signaldash 0.30.0 → 0.32.2

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/README.md CHANGED
@@ -96,11 +96,13 @@ the same logged-in user.
96
96
  The canonical [`signaldash` skill](skills/signaldash/SKILL.md) teaches an agent
97
97
  when to use SignalDash, how to persist the skill, complete setup, interpret
98
98
  connection state, operate every tool, handle server guards, and run safe worked
99
- flows. The one-command setup installs the skill from the same pinned npm package
99
+ flows. The thin [`secretary` skill](skills/secretary/SKILL.md) renders the
100
+ stored `latest` result as one table and has no write authority. The one-command
101
+ setup installs both from the same pinned npm package
100
102
  the human chose to execute:
101
103
 
102
104
  ```bash
103
- npx -y @floomhq/signaldash@0.30.0 <invite-code>
105
+ npx -y @floomhq/signaldash@0.32.2 <invite-code>
104
106
  ```
105
107
 
106
108
  Run that command in a terminal, not in an agent chat. Do not ask an agent to
@@ -117,9 +119,14 @@ available for clients that separate operating and send-safety instructions.
117
119
 
118
120
  SignalDash exposes:
119
121
 
122
+ - `sd_version()`
123
+ - `sd_secretary_latest(limit?, continuation?, ack_token?)`
124
+ - `sd_secretary_approve(disposition_id, payload_hash, confirm)`
125
+ - `sd_secretary_reject(disposition_id, confirm)`
120
126
  - `li_list_chats`
121
127
  - `li_read_messages(chat_id)`
122
- - `li_send_message(chat_id, text)`
128
+ - `li_mark_read(chat_id)`
129
+ - `li_send_message(chat_id?, text?, expected_watermark?, mark_read?, secretary_receipt_id?)`
123
130
  - `li_send_invitation(provider_id, note?, confirm?)`
124
131
  - `li_invitations_received(limit?, cursor?)`
125
132
  - `li_accept_invitation(invitation_id, confirm)`
@@ -147,17 +154,21 @@ SignalDash exposes:
147
154
  - `sd_withdrawal_batch_cancel(withdrawal_batch_id, approval_view_hash, confirm)`
148
155
  - `wa_list_chats`
149
156
  - `wa_read_messages(chat_id)`
157
+ - `wa_mark_read(chat_id)`
150
158
  - `wa_get_attachment(chat_id, message_id, attachment_id)`
151
159
  - `wa_transcribe_voice(chat_id, message_id, attachment_id)`
152
- - `wa_send_message(chat_id, text?, attachments?)`
160
+ - `wa_send_message(chat_id, text?, attachments?, expected_watermark?, mark_read?)`
153
161
  - `wa_delete_message(chat_id, message_id)`
154
162
  - `wa_delete_messages(messages)`
155
163
  - `email_list(limit)`
156
164
  - `email_read(thread_id, limit)`
157
165
  - `email_send(to, subject, body)`
158
166
  - `li_my_posts(limit, member_id)`
159
- - `li_post_reactions(post_id, limit)`
160
- - `li_post_comments(post_id, limit)`
167
+ - `li_post_reactions(post_id, limit, cursor?)`
168
+ - `li_post_comments(post_id, comment_id?, limit, cursor?)`
169
+ - `li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, expected_watermark?, secretary_receipt_id?)`
170
+ - `li_delete_message(chat_id, message_id, confirm)`
171
+ - `li_delete_comment(post_id, comment_id, confirm)`
161
172
  - `li_draft_post(text, publish, scheduled_at?, mentions?, attachments?, first_comment?)`
162
173
  - `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
163
174
  - `li_scheduled_posts()`
@@ -169,6 +180,24 @@ SignalDash exposes:
169
180
  Every operation runs through the hosted SignalDash backend. Agents never
170
181
  receive the Unipile access key.
171
182
 
183
+ `li_reply_to_comment` acts only on an inbound comment on the authenticated
184
+ sender's own post. A preceding `li_post_comments` read records an exact
185
+ per-comment watermark. Immediately before the write, SignalDash re-proves the
186
+ post owner, sender generation, trigger author, unchanged trigger, parent
187
+ thread, absence of an own duplicate, action budget and sender health. A
188
+ provider 2xx remains `outcome_unknown` until bounded readback finds exactly one
189
+ own reply with the returned reply ID, expected parent and exact text.
190
+
191
+ LinkedIn deletion is remediation after exposure, not rollback. It never
192
+ relaxes a send or Secretary gate. `li_delete_message` is limited to the first
193
+ 60 minutes after the provider send timestamp and proves exact account, chat,
194
+ message and own authorship before using a separate remediation budget. It
195
+ marks success only after a readback proves the message absent. The v2
196
+ `li_delete_comment` wrapper is installed behind the database capability flag
197
+ `linkedin_comment_delete`, whose migration default is `untested`. It is not an
198
+ available live capability until an exact removable comment on Federico's own
199
+ post passes compatibility testing and the flag is separately enabled.
200
+
172
201
  A WhatsApp message can carry files. `wa_send_message` takes up to 4 exact
173
202
  `{filename, content_type, content_base64}` attachments, at most 16 MiB per file
174
203
  and 16 MiB per message, and accepts PNG, JPEG, WebP, GIF, PDF, CSV, plain text,
@@ -182,16 +211,23 @@ oversized attachment costs no send budget and never reaches the provider, and
182
211
  every refusal names the exact rule and the exact file that tripped it. Nothing
183
212
  is ever truncated or dropped silently. LinkedIn messages carry text only, and
184
213
  `li_send_message` refuses an `attachments` argument instead of ignoring it.
214
+ For a Secretary draft, `sd_secretary_approve` creates one 15-minute receipt
215
+ bound to the exact stored sender generation, recipient, thread, and text.
216
+ `li_send_message` then accepts that receipt ID alone, derives the payload from
217
+ storage, refuses caller-supplied overrides, and consumes the receipt atomically
218
+ with the action reservation. Rejection records a new human disposition and
219
+ sends nothing.
185
220
 
186
221
  A recent read is no longer sufficient on its own. A message send re-reads the
187
- thread immediately before sending and refuses with `409 thread_changed` unless
188
- it is still the exact thread that was read, which is the guard LinkedIn has
189
- always had and WhatsApp did not: a 30-minute read window is satisfied just as
190
- comfortably by a conversation that moved 29 minutes ago as by one nobody has
191
- touched, and a reply that arrived in between is precisely when a draft stops
192
- being the right thing to send. The comparison is a hash over the page that was
193
- read, taken at the same item limit, so the re-read has to match message for
194
- message. A chat whose most recent read predates this guard carries no such hash
222
+ thread immediately before sending. A counterparty message or a mutation after
223
+ the read refuses with `409 thread_changed`; an addition consisting only of this
224
+ account's own outbound messages does not invalidate the draft. The 409 includes
225
+ `new_messages`, `changed_kind`, and `current_watermark`, and both send tools
226
+ accept optional `expected_watermark` to bind a retry to the exact state the
227
+ caller reviewed. A 30-minute read window is satisfied just as comfortably by a
228
+ conversation that moved 29 minutes ago as by one nobody has touched, and a
229
+ reply that arrived in between is precisely when a draft stops being the right
230
+ thing to send. A chat whose most recent read predates this guard carries no hash
195
231
  and is refused with `428 read_before_send_required` until it is read again,
196
232
  rather than waved through on a timestamp that cannot answer the question. If the
197
233
  re-read itself fails, the send is refused with
@@ -419,6 +455,31 @@ resume exactly those. WhatsApp applies its own time and role limits to deleting
419
455
  for everyone and can answer successfully without removing anything, so re-read
420
456
  the chat to confirm.
421
457
 
458
+ ## Deploying server changes
459
+
460
+ A server change is not landed when only the files on disk changed. After the
461
+ change is merged to `main`, update the production checkout, restart the
462
+ long-running backend, and compare the live MCP result with the checked-out
463
+ commit:
464
+
465
+ ```bash
466
+ git pull --ff-only origin main
467
+ git rev-parse HEAD
468
+ sudo systemctl restart signaldash-api.service
469
+ sudo systemctl is-active signaldash-api.service
470
+ ```
471
+
472
+ Then call `sd_version({})` from an authenticated MCP session. Its `commit_sha`
473
+ must equal `git rev-parse HEAD`, and its `process_started_at` must be later than
474
+ the restart. The startup journal contains the same snapshot under the
475
+ `[signaldash] startup` prefix. A mismatch means the deployment is stale and the
476
+ server change is not shipped. `dirty_at_start` includes tracked changes,
477
+ untracked files, and submodule state present when the process started.
478
+
479
+ `SIGNALDASH_VERSION_ENABLED=0` removes the version route at runtime after a
480
+ service restart. It defaults to enabled and exposes no credential, account, or
481
+ message data.
482
+
422
483
  ## Releasing
423
484
 
424
485
  A release is a tag push. `.github/workflows/release.yml` runs `./release.sh` on
@@ -471,6 +532,32 @@ npm run check
471
532
  npm pack --dry-run
472
533
  ```
473
534
 
535
+ ### Offline LinkedIn archive import
536
+
537
+ The server-side importer reads LinkedIn export directories or ZIP files without
538
+ making provider requests. Preview the exact file fingerprints and row counts,
539
+ then apply the same archive to one bound LinkedIn sender generation:
540
+
541
+ ```bash
542
+ node scripts/import-linkedin-archive.mjs \
543
+ --archive /path/to/Complete_LinkedInDataExport \
544
+ --database server/sd-state-control.sqlite3 \
545
+ --sender-account <exact-sender-account-id> \
546
+ --owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
547
+ --dry-run
548
+
549
+ node scripts/import-linkedin-archive.mjs \
550
+ --archive /path/to/Complete_LinkedInDataExport \
551
+ --database server/sd-state-control.sqlite3 \
552
+ --sender-account <exact-sender-account-id> \
553
+ --owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
554
+ --apply
555
+ ```
556
+
557
+ The owner profile URL is required when `messages.csv` is present. It is the
558
+ typed identity used to classify inbound and outbound rows. A display name is
559
+ never used for that decision.
560
+
474
561
  `wa_transcribe_voice` runs on the SignalDash host and has two backends. The
475
562
  default, `gemini`, sends the audio to Vertex AI using that host's own
476
563
  credentials; it is the one that survives German speech with English technical
package/bin/sd.mjs CHANGED
@@ -242,7 +242,8 @@ export async function cmdConnect(provider, dependencies = {}) {
242
242
  process.exitCode = 1;
243
243
  return;
244
244
  }
245
- log(` To connect ${provider}, open this link in your browser and click Connect: ${handoff.json.url}`);
245
+ log(`\n Open this in your browser to connect ${provider}, then click Connect:`);
246
+ log(` ${handoff.json.url}\n`);
246
247
  const { open: defaultOpen } = await ui();
247
248
  const openUrl = dependencies.open || defaultOpen;
248
249
  try { await openUrl(handoff.json.url); } catch {}
@@ -297,18 +298,72 @@ export async function cmdClaim(provider, accountId, dependencies = {}) {
297
298
 
298
299
  // ---- MCP (stdio). Every tool proxies through the backend with the token. ----
299
300
  const TOOLS = [
301
+ {
302
+ name: "sd_version",
303
+ path: "/sd/version",
304
+ description: "Report the exact SignalDash backend commit, working-tree state, process start time, and package version captured when the serving process started.",
305
+ inputSchema: {
306
+ type: "object",
307
+ properties: {},
308
+ additionalProperties: false,
309
+ },
310
+ },
311
+ {
312
+ name: "sd_secretary_latest",
313
+ path: "/sd/secretary/latest",
314
+ description: "Read stored Secretary signals with zero provider calls. Render rows and the ignored aggregate as one table. On the next call, pass the previous ack_token; pass next as continuation when it is non-null.",
315
+ inputSchema: {
316
+ type: "object",
317
+ properties: {
318
+ limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
319
+ continuation: { type: "string", minLength: 1, maxLength: 4096 },
320
+ ack_token: { type: "string", minLength: 1, maxLength: 4096 },
321
+ },
322
+ additionalProperties: false,
323
+ },
324
+ },
325
+ {
326
+ name: "sd_secretary_approve",
327
+ path: "/sd/secretary/approve",
328
+ description: "Approve one exact stored Secretary draft. Pass only the disposition_id and payload_hash returned by sd_secretary_latest plus confirm:true. The result is a single-use 15-minute receipt.",
329
+ inputSchema: {
330
+ type: "object",
331
+ properties: {
332
+ disposition_id: { type: "string", minLength: 1, maxLength: 200 },
333
+ payload_hash: { type: "string", pattern: "^[0-9a-f]{64}$" },
334
+ confirm: { type: "boolean", const: true },
335
+ },
336
+ required: ["disposition_id", "payload_hash", "confirm"],
337
+ additionalProperties: false,
338
+ },
339
+ },
340
+ {
341
+ name: "sd_secretary_reject",
342
+ path: "/sd/secretary/reject",
343
+ description: "Reject one exact current stored Secretary draft and create a durable rejected disposition. Sends nothing.",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ disposition_id: { type: "string", minLength: 1, maxLength: 200 },
348
+ confirm: { type: "boolean", const: true },
349
+ },
350
+ required: ["disposition_id", "confirm"],
351
+ additionalProperties: false,
352
+ },
353
+ },
300
354
  {
301
355
  name: "li_list_chats",
302
356
  ch: "li",
303
357
  action: "list_chats",
304
- description: "List or search your LinkedIn chats. `search` filters on identifiers, never on a person name (a 1:1 chat has no name); the response reports how far it scanned.",
358
+ description: "List or search your LinkedIn chats by display name, public_id, member id, or chat fields. Pass unread:true to make the provider return unread chats only. A unique stored connection name or exact public_id is resolved to its verified LinkedIn member id before matching. A zero-match default search continues from 200 up to a hard 500-chat/five-page bound and reports the exact scan depth.",
305
359
  inputSchema: {
306
360
  type: "object",
307
361
  properties: {
308
362
  limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
309
363
  cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
310
- search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
311
- max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
364
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on a unique stored connection display name, exact LinkedIn public_id, verified member id, or chat fields. SignalDash resolves a stored name/public_id to its member id before the bounded chat scan." },
365
+ max_scan: { type: "integer", minimum: 1, maximum: 500, description: "Explicit scan bound. Without it, SignalDash scans 200 chats and, only after zero matches with more pages available, continues up to 500. Every search is capped at five provider pages and reports the real depth." },
366
+ unread: { type: "boolean", description: "Ask the LinkedIn provider for unread chats only. true is forwarded as unread=true; omitted or false does not apply the provider filter." },
312
367
  },
313
368
  additionalProperties: false,
314
369
  },
@@ -317,7 +372,7 @@ const TOOLS = [
317
372
  name: "li_read_messages",
318
373
  ch: "li",
319
374
  action: "read",
320
- description: "Read messages in a LinkedIn chat. args: chat_id",
375
+ description: "Read messages and the resolved chat in one LinkedIn thread. Messages include sender_name where the exact sender can be resolved. args: chat_id",
321
376
  inputSchema: {
322
377
  type: "object",
323
378
  properties: {
@@ -328,18 +383,34 @@ const TOOLS = [
328
383
  additionalProperties: false,
329
384
  },
330
385
  },
386
+ {
387
+ name: "li_mark_read",
388
+ ch: "li",
389
+ action: "mark_read",
390
+ description: "Explicitly clear the unread state of one account-scoped LinkedIn chat. Reading a thread never clears it automatically.",
391
+ inputSchema: {
392
+ type: "object",
393
+ properties: {
394
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
395
+ },
396
+ required: ["chat_id"],
397
+ additionalProperties: false,
398
+ },
399
+ },
331
400
  {
332
401
  name: "li_send_message",
333
402
  ch: "li",
334
403
  action: "send",
335
- description: "Send a LinkedIn message (rate-safe). args: chat_id, text",
404
+ description: "Send a LinkedIn message (rate-safe). For a manual send, pass chat_id and text after reading the exact thread. For an approved Secretary draft, pass secretary_receipt_id alone; recipient and text are derived from storage and any override is refused. Own outbound messages added after a read do not invalidate the draft, but a counterparty message or mutation returns 409 thread_changed.",
336
405
  inputSchema: {
337
406
  type: "object",
338
407
  properties: {
339
408
  chat_id: { type: "string", minLength: 1, maxLength: 500 },
340
409
  text: { type: "string", minLength: 1, maxLength: 5000 },
410
+ expected_watermark: { type: "string", minLength: 64, maxLength: 64, pattern: "^[0-9a-f]{64}$", description: "Bind the send to the exact current_watermark returned by the read or a thread_changed response." },
411
+ mark_read: { type: "boolean", default: false, description: "After a confirmed successful send, explicitly clear this chat's unread state." },
412
+ secretary_receipt_id: { type: "string", minLength: 1, maxLength: 200, description: "Single-use receipt from sd_secretary_approve. Pass this field alone." },
341
413
  },
342
- required: ["chat_id", "text"],
343
414
  additionalProperties: false,
344
415
  },
345
416
  },
@@ -554,7 +625,7 @@ const TOOLS = [
554
625
  {
555
626
  name: "li_search_connections",
556
627
  path: "/li/connections/search",
557
- description: "Search only your locally stored LinkedIn connections by name, headline, or company and join exact local contact state. Makes zero LinkedIn, Unipile, or paid discovery API calls.",
628
+ description: "Search only your locally stored LinkedIn connections by name, headline, or company. Returns both public_id and the stable LinkedIn member_id when known, plus exact local contact and known-chat state. Makes zero LinkedIn, Unipile, or paid discovery API calls.",
558
629
  inputSchema: {
559
630
  type: "object",
560
631
  properties: {
@@ -968,13 +1039,13 @@ const TOOLS = [
968
1039
  name: "wa_list_chats",
969
1040
  ch: "wa",
970
1041
  action: "list_chats",
971
- description: "List or search your WhatsApp chats. `search` matches the phone number (chats carry it as attendee_public_identifier), not a person name; the response reports how far it scanned.",
1042
+ description: "List or search your WhatsApp chats. Resolved 1:1 chats expose the contact in both name and attendee_name; unresolved names stay null. A search reports exactly how far it scanned.",
972
1043
  inputSchema: {
973
1044
  type: "object",
974
1045
  properties: {
975
1046
  limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
976
1047
  cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
977
- search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. A 1:1 chat has no name, so search by identifier or phone number, not by person name." },
1048
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats on resolved name and counterpart identifiers. The provider cannot filter a chat list, so SignalDash filters over a bounded scan and the response reports `scanned_chats` and `exhaustive` so a miss is never mistaken for a proven absence. Search by the exact phone number when a 1:1 name remains unresolved." },
978
1049
  max_scan: { type: "integer", minimum: 1, maximum: 500, description: "How many chats the search may scan before it stops and reports exhaustive:false. Default 200, and never more than five provider pages: bulk reading is the top account-restriction trigger, so go deeper with the returned cursor rather than with a bigger scan." },
979
1050
  },
980
1051
  additionalProperties: false,
@@ -984,7 +1055,7 @@ const TOOLS = [
984
1055
  name: "wa_read_messages",
985
1056
  ch: "wa",
986
1057
  action: "read",
987
- description: "Read messages in a WhatsApp chat. args: chat_id",
1058
+ description: "Read messages and the resolved chat in one WhatsApp thread. Messages include sender_name where the exact sender can be resolved. args: chat_id",
988
1059
  inputSchema: {
989
1060
  type: "object",
990
1061
  properties: {
@@ -995,6 +1066,20 @@ const TOOLS = [
995
1066
  additionalProperties: false,
996
1067
  },
997
1068
  },
1069
+ {
1070
+ name: "wa_mark_read",
1071
+ ch: "wa",
1072
+ action: "mark_read",
1073
+ description: "Explicitly clear the unread state of one account-scoped WhatsApp chat. Reading a thread never clears it automatically.",
1074
+ inputSchema: {
1075
+ type: "object",
1076
+ properties: {
1077
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1078
+ },
1079
+ required: ["chat_id"],
1080
+ additionalProperties: false,
1081
+ },
1082
+ },
998
1083
  {
999
1084
  name: "wa_get_attachment",
1000
1085
  path: "/wa/get_attachment",
@@ -1030,7 +1115,7 @@ const TOOLS = [
1030
1115
  name: "wa_send_message",
1031
1116
  ch: "wa",
1032
1117
  action: "send",
1033
- description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments. Read the chat first: a send into a thread this account has not read recently is refused. The send re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after that read, because a draft written against the old thread may now be deaf or wrong; re-read, revise, and send again. A re-read that fails is `502 thread_preflight_unavailable` and nothing was sent. `attachments` optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. `text` is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with `409 send_outcome_unknown`. Read the chat, and only if the message is genuinely absent, resend the identical payload with `confirm_resend:true`.",
1118
+ description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments, expected_watermark, mark_read. Read the chat first: the read returns current_watermark and a send into a thread this account has not read recently is refused. Own outbound messages added after that read do not invalidate the draft, but a counterparty message or mutation returns 409 thread_changed with new_messages, changed_kind, and current_watermark. Optional expected_watermark binds the send to that exact reviewed state. A re-read that fails is 502 thread_preflight_unavailable and nothing was sent. attachments optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. text is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. Optional mark_read:true explicitly clears unread only after the send is confirmed. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with 409 send_outcome_unknown. Read the chat, and only if the message is genuinely absent, resend the identical payload with confirm_resend:true.",
1034
1119
  inputSchema: {
1035
1120
  type: "object",
1036
1121
  properties: {
@@ -1063,6 +1148,8 @@ const TOOLS = [
1063
1148
  },
1064
1149
  },
1065
1150
  confirm_resend: { type: "boolean", const: true },
1151
+ expected_watermark: { type: "string", minLength: 64, maxLength: 64, pattern: "^[0-9a-f]{64}$", description: "Bind the send to the exact current_watermark returned by the read or a thread_changed response." },
1152
+ mark_read: { type: "boolean", default: false, description: "After a confirmed successful send, explicitly clear this chat's unread state." },
1066
1153
  },
1067
1154
  // `text` alone is no longer required: a document with no caption is a
1068
1155
  // legitimate message.
@@ -1187,7 +1274,7 @@ const TOOLS = [
1187
1274
  {
1188
1275
  name: "li_my_posts",
1189
1276
  path: "/li/posts",
1190
- description: "List the user's own LinkedIn posts with engagement counts (reactions, comments, impressions). args: limit",
1277
+ description: "List LinkedIn posts with engagement counts (reactions, comments, impressions). Use each row's exact social_id, not a different numeric id, with li_post_reactions and li_post_comments.",
1191
1278
  inputSchema: {
1192
1279
  type: "object",
1193
1280
  properties: {
@@ -1200,12 +1287,13 @@ const TOOLS = [
1200
1287
  {
1201
1288
  name: "li_post_reactions",
1202
1289
  path: "/li/post_reactions",
1203
- description: "Who reacted to a post name + headline. These are warm inbound signals. args: post_id, limit",
1290
+ description: "Read one bounded page of people who reacted to a post. Pass the exact social_id returned by li_my_posts (including urn:li:ugcPost:... for multi-image posts); a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash does not invent one from li_my_posts. Reactions are signals only and do not authorize outreach.",
1204
1291
  inputSchema: {
1205
1292
  type: "object",
1206
1293
  properties: {
1207
1294
  post_id: { type: "string", minLength: 1, maxLength: 500 },
1208
1295
  limit: { type: "integer", minimum: 1, maximum: 100 },
1296
+ cursor: { type: "string", minLength: 1, maxLength: 4000 },
1209
1297
  },
1210
1298
  required: ["post_id"],
1211
1299
  additionalProperties: false,
@@ -1214,17 +1302,66 @@ const TOOLS = [
1214
1302
  {
1215
1303
  name: "li_post_comments",
1216
1304
  path: "/li/post_comments",
1217
- description: "Comments on a post, with author. args: post_id, limit",
1305
+ description: "Read one bounded page of comments and authors on a post. Pass comment_id to read replies to that exact parent comment. Pass the exact social_id returned by li_my_posts; a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash never guesses it from li_my_posts.",
1218
1306
  inputSchema: {
1219
1307
  type: "object",
1220
1308
  properties: {
1221
1309
  post_id: { type: "string", minLength: 1, maxLength: 500 },
1310
+ comment_id: { type: "string", minLength: 1, maxLength: 500, description: "Optional exact parent comment id. When present, returns replies to that comment." },
1222
1311
  limit: { type: "integer", minimum: 1, maximum: 100 },
1312
+ cursor: { type: "string", minLength: 1, maxLength: 4000 },
1223
1313
  },
1224
1314
  required: ["post_id"],
1225
1315
  additionalProperties: false,
1226
1316
  },
1227
1317
  },
1318
+ {
1319
+ name: "li_reply_to_comment",
1320
+ path: "/li/reply_to_comment",
1321
+ description: "Reply once to one exact inbound comment on the authenticated sender's own LinkedIn post. First read li_post_comments and preserve the exact post_id, parent_comment_id, trigger_comment_id, text, and returned signaldash_watermark. SignalDash re-proves post ownership, trigger identity and content, absence of an own duplicate, sender generation, budget, and provider safety immediately before writing. A 2xx is not success until bounded readback finds exactly one own reply. A Secretary approval uses secretary_receipt_id alone.",
1322
+ inputSchema: {
1323
+ type: "object",
1324
+ properties: {
1325
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1326
+ parent_comment_id: { type: "string", minLength: 1, maxLength: 500 },
1327
+ trigger_comment_id: { type: "string", minLength: 1, maxLength: 500 },
1328
+ text: { type: "string", minLength: 1, maxLength: 1250 },
1329
+ expected_watermark: { type: "string", pattern: "^[0-9a-f]{64}$" },
1330
+ secretary_receipt_id: { type: "string", minLength: 1, maxLength: 500 },
1331
+ },
1332
+ additionalProperties: false,
1333
+ },
1334
+ },
1335
+ {
1336
+ name: "li_delete_message",
1337
+ path: "/li/delete_message",
1338
+ description: "Remediate one exact LinkedIn message sent by this authenticated account, only within the provider's 60-minute window. Requires exact chat and message identity plus confirm:true. SignalDash proves chat ownership, exact-chat membership, own authorship, timestamp eligibility, a separate remediation budget, and post-delete absence. Deletion cannot undo prior delivery, reading, or notifications and does not weaken any send gate.",
1339
+ inputSchema: {
1340
+ type: "object",
1341
+ properties: {
1342
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1343
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1344
+ confirm: { type: "boolean", const: true },
1345
+ },
1346
+ required: ["chat_id", "message_id", "confirm"],
1347
+ additionalProperties: false,
1348
+ },
1349
+ },
1350
+ {
1351
+ name: "li_delete_comment",
1352
+ path: "/li/delete_comment",
1353
+ description: "UNTESTED AND DISABLED by the database capability flag. The guarded Unipile v2 wrapper is fixture-tested but is unavailable until live compatibility is proved against the authenticated sender's own removable comment. When enabled it requires exact post_id, comment_id, confirm:true, own-comment proof, a separate remediation budget, and post-delete readback. Deletion is remediation, not rollback.",
1354
+ inputSchema: {
1355
+ type: "object",
1356
+ properties: {
1357
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1358
+ comment_id: { type: "string", minLength: 1, maxLength: 500 },
1359
+ confirm: { type: "boolean", const: true },
1360
+ },
1361
+ required: ["post_id", "comment_id", "confirm"],
1362
+ additionalProperties: false,
1363
+ },
1364
+ },
1228
1365
  {
1229
1366
  name: "li_draft_post",
1230
1367
  path: "/li/create_post",
@@ -1407,20 +1544,23 @@ export async function cmdSkill(dependencies = {}) {
1407
1544
  const { mkdirSync: mk, writeFileSync: wf, readFileSync: rf, existsSync: ex } = await import("node:fs");
1408
1545
  const { dirname, join } = await import("node:path");
1409
1546
  const here = dirname(fileURLToPath(import.meta.url));
1410
- const src = join(here, "..", "skills", "signaldash", "SKILL.md");
1411
- if (!ex(src)) { log("skill file not found in package"); process.exitCode = 1; return; }
1412
- const dest = join(homedir(), ".claude", "skills", "signaldash");
1413
- mk(dest, { recursive: true });
1547
+ const skills = ["signaldash", "secretary"];
1414
1548
  // The template pins the bootstrap command to a version placeholder rather
1415
1549
  // than a typed-in string: package.json is the only source of truth, so an
1416
1550
  // installed skill can never advertise a stale published release. Rendered
1417
1551
  // by the shared helper (lib/skill-template.cjs) so this substitution has a
1418
1552
  // single source of truth shared with the public skill page and the
1419
1553
  // server's own /skill and /skill.md routes.
1420
- const template = rf(src, "utf8");
1421
- wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
1422
- log(`Installed the SignalDash skill to ${dest}/SKILL.md`);
1423
- log("Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.");
1554
+ for (const name of skills) {
1555
+ const src = join(here, "..", "skills", name, "SKILL.md");
1556
+ if (!ex(src)) { log(`${name} skill file not found in package`); process.exitCode = 1; return; }
1557
+ const dest = join(homedir(), ".claude", "skills", name);
1558
+ mk(dest, { recursive: true });
1559
+ const template = rf(src, "utf8");
1560
+ wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
1561
+ log(`Installed the ${name === "signaldash" ? "SignalDash" : "Secretary"} skill to ${dest}/SKILL.md`);
1562
+ }
1563
+ log("Your agent now knows how to use SignalDash safely and render Secretary latest.");
1424
1564
  }
1425
1565
 
1426
1566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.30.0",
3
+ "version": "0.32.2",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "node --test",
21
- "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/write-control.cjs && node --check server/voice-profile.cjs && node --check server/campaign-store.cjs && node --check server/campaign-runner.cjs && node --check server/withdrawal-store.cjs && node --check server/post-scheduler.cjs && node --check server/message-scheduler.cjs && node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/config-file.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
21
+ "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/write-control.cjs && node --check server/secretary-store.cjs && node --check server/secretary-collector.cjs && node --check server/secretary-latest.cjs && node --check server/secretary-approvals.cjs && node --check server/secretary-rules.cjs && node --check server/secretary-shadow-gate.cjs && node --check server/secretary-vault-importer.cjs && node --check server/linkedin-archive-importer.cjs && node --check server/voice-profile.cjs && node --check server/campaign-store.cjs && node --check server/campaign-runner.cjs && node --check server/withdrawal-store.cjs && node --check server/post-scheduler.cjs && node --check server/message-scheduler.cjs && node --check scripts/import-linkedin-archive.mjs && node --check scripts/import-secretary-vault-snapshot.mjs && node --check scripts/rebuild-secretary-actors.mjs && node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/config-file.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
22
22
  },
23
23
  "keywords": [
24
24
  "mcp",
@@ -0,0 +1,66 @@
1
+ ---
2
+ name: secretary
3
+ description: Render Federico's stored inbound SignalDash triage when he asks for "latest", then record an exact yes or no for one displayed draft through SignalDash.
4
+ ---
5
+
6
+ # Secretary latest
7
+
8
+ When Federico asks `latest`, call `sd_secretary_latest`. This is a stored-data
9
+ read. Do not call LinkedIn, list chats, read threads, draft with a model, or use
10
+ any provider-backed tool as part of this flow.
11
+
12
+ Keep the most recent successful response's `ack_token` and nullable `next`
13
+ inside the current Claude session. On the next `latest` call:
14
+
15
+ - pass the prior `ack_token` as `ack_token`;
16
+ - when the prior `next` was non-null, pass it as `continuation` too;
17
+ - replace both saved values only after a successful response;
18
+ - after any error, retain the prior values and report the exact error.
19
+
20
+ Render exactly one Markdown table. Before it, state collection warnings from
21
+ `completeness` in one short sentence, or state `Collection complete` when the
22
+ array is empty. Use these columns:
23
+
24
+ | Priority | When | Who | Channel | Exact inbound | Disposition | Action | Draft |
25
+ |---|---|---|---|---|---|---|---|
26
+
27
+ Keep the server order. Copy exact inbound text and draft text byte for byte,
28
+ using `<br>` only to render embedded line breaks inside a table cell. Escape
29
+ literal pipe characters for Markdown without changing their meaning. Never
30
+ invent an actor name, summary, disposition, recipient, or draft.
31
+
32
+ Use these priority labels for returned categories:
33
+
34
+ - `decisions_federico_owes`: Decision
35
+ - `drafts_waiting_for_yes`: Yes or no
36
+ - `shadow_since_last_asked`: Shadow
37
+ - `auto_sent_since_last_asked`: Auto-sent
38
+ - `leads_routed_to_beatriz`: Beatriz
39
+
40
+ After all returned rows, add exactly one final table row for `ignored`, even
41
+ when its total is zero. Put `Ignored` in Priority, the aggregate total and
42
+ reason counts in Action, and `-` in the other cells. When there are no returned
43
+ rows, this ignored row is still the only table row.
44
+
45
+ For a draft, display `approval_view.text` in Draft. For a shadow row, display
46
+ `shadow_view.exact_text` in Draft when it is non-null, byte for byte. A shadow
47
+ view is review evidence only and never an approval surface. A missing approval view is
48
+ not approval-ready. Ask Federico for yes or no only when the exact immutable
49
+ approval view is present. The voice evidence comes from
50
+ `approval_view.draft_voice`: measured LinkedIn corpus, median 7 words,
51
+ lowercase openers, zero observed em dashes, German for German input and English
52
+ otherwise. Never replace that evidence with a generic style prompt.
53
+
54
+ On an explicit yes to one exact displayed draft, call
55
+ `sd_secretary_approve` once with only its `disposition_id`, `payload_hash`, and
56
+ `confirm:true`. Then call `li_send_message` once with only the returned
57
+ `secretary_receipt_id`. Never pass a chat, recipient, text, attachment,
58
+ watermark, or mark-read field beside the receipt. The server derives the exact
59
+ payload and runs the existing read, freshness, duplicate, budget, queue,
60
+ warning, and outcome guards. Report the exact refusal when any guard stops it.
61
+
62
+ On an explicit no, call `sd_secretary_reject` once with only the displayed
63
+ `disposition_id` and `confirm:true`. This records the rejection and sends
64
+ nothing. Discussion, an edit, or approval of another draft is neither yes nor
65
+ no for the displayed payload. An edit requires a new current disposition and
66
+ a new approval view.
@@ -51,7 +51,7 @@ in their terminal first, then continue.
51
51
  npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
52
52
  ```
53
53
 
54
- This logs in, copies the package-bundled skill into Claude Code, registers
54
+ This logs in, copies the package-bundled SignalDash and Secretary skills into Claude Code, registers
55
55
  the local MCP server, and prints browser links the human opens to connect
56
56
  each account.
57
57
 
@@ -59,17 +59,19 @@ in their terminal first, then continue.
59
59
  the provider account link. Hand the link to the user and wait for them to
60
60
  complete Connect in their own browser.
61
61
 
62
- 2. The package writes the installed copy to the one location it supports
62
+ 2. The package writes the installed copies to the one client layout it supports
63
63
  today:
64
64
 
65
65
  - Claude Code: `~/.claude/skills/signaldash/SKILL.md`
66
+ - Claude Code: `~/.claude/skills/secretary/SKILL.md`
66
67
 
67
68
  Other clients (Codex, a portable `.agents/skills/` layout, etc.) are not
68
69
  written automatically. Copy this same reviewed file into that client's own
69
70
  skill directory by hand and reload skill discovery there.
70
71
 
71
- 3. Confirm that the installed file begins with `name: signaldash` and contains the
72
- `Before every send` section. A partial copy is not installed.
72
+ 3. Confirm that the SignalDash file begins with `name: signaldash` and contains the
73
+ `Before every send` section, and the Secretary file begins with `name: secretary`.
74
+ A partial copy is not installed.
73
75
 
74
76
  4. Enable or reload skills according to the current agent. Start a new agent
75
77
  session when the client discovers skills only at startup. After SignalDash
@@ -86,7 +88,8 @@ in their terminal first, then continue.
86
88
 
87
89
  ```text
88
90
  Installed the SignalDash skill to /home/user/.claude/skills/signaldash/SKILL.md
89
- Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.
91
+ Installed the Secretary skill to /home/user/.claude/skills/secretary/SKILL.md
92
+ Your agent now knows how to use SignalDash safely and render Secretary latest.
90
93
  ```
91
94
 
92
95
  Persisting the reviewed, package-bundled skill is part of setup. Do not continue
@@ -359,9 +362,14 @@ Use the exact tool names and argument keys below. Limits are optional.
359
362
 
360
363
  | Tool | Arguments | When to use it |
361
364
  |---|---|---|
362
- | `li_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200, and never more than five provider pages | Find recent LinkedIn chats, unread counts, and exact `chat_id` values without opening profiles. With `search` it filters on `name`, `subject`, `id`, `provider_id` and `attendee_provider_id`. The provider CANNOT filter a chat list, so SignalDash scans and filters: read `search.exhaustive` before you conclude anything, `search.limit_reached` to know whether the matches were truncated by `limit`, and `search.searched_fields` for what was actually compared. `false` means "not among the `scanned_chats` scanned", never "does not exist" -- continue from the returned `cursor`. When `started_from_cursor` is true the scan began mid-list, so even `exhaustive` says nothing about the chats before that cursor. A 1:1 chat has `name: null`, so a person's NAME can never match; resolve it with `li_search_connections` and search for the member id. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter` rather than accepted and ignored. |
363
- | `li_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Read one resolved LinkedIn conversation before summarizing, drafting, or sending. |
364
- | `li_send_message` | `chat_id` required; `text` required, max 5000 characters | Send one approved LinkedIn reply after an immediate read of that exact chat. |
365
+ | `sd_version` | no arguments | Read the backend commit, dirty-at-start state, process start time, and package version captured when the serving process started. Use it to distinguish stale deployment from a code failure. |
366
+ | `sd_secretary_latest` | `limit` optional integer 1-100, default 50; `continuation` optional signed token; `ack_token` optional signed token from the previous successful response | Read the stored Secretary inbox with zero provider calls. Render its rows plus the ignored aggregate as one table. Preserve the prior tokens after an error; on the next successful call pass its acknowledgement and continuation exactly as returned. |
367
+ | `sd_secretary_approve` | `disposition_id` required; `payload_hash` required exact 64-character hash from latest; `confirm:true` required | Create one single-use, 15-minute receipt for the exact current stored draft. It derives the sender and executable payload from storage. |
368
+ | `sd_secretary_reject` | `disposition_id` required; `confirm:true` required | Reject one exact current stored draft. It sends nothing. |
369
+ | `li_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500; `unread` optional boolean | Find recent LinkedIn chats, unread counts, and exact `chat_id` values. `unread:true` is forwarded to the provider as `unread=true`; SignalDash does not fetch a full chat page and filter it locally. Search accepts a unique stored display name, exact `public_id`, member id, or ordinary chat field. A stored name/public id is resolved to its verified member id before matching, so a provider row with `name:null` remains findable. Without an explicit `max_scan`, a zero-match search extends from 200 up to a hard 500-chat/five-page bound; explicit bounds remain exact. Read `scanned_chats`, `pages_fetched`, `scan_limit`, and `exhaustive` before concluding absence. The response also reports any resolved public/member ids. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter` rather than accepted and ignored. |
370
+ | `li_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Read one resolved LinkedIn conversation before summarizing, drafting, or sending. The response includes `current_watermark`, the resolved `chat`, and `sender_name` on each message where the exact sender identity can be resolved. |
371
+ | `li_mark_read` | `chat_id` required | Explicitly clear one LinkedIn chat's unread state. Reading never clears unread automatically. |
372
+ | `li_send_message` | Manual path: `chat_id` and `text` required; `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean. Secretary path: `secretary_receipt_id` alone. | Send one approved LinkedIn reply after an immediate read of that exact chat. A Secretary receipt derives the exact stored chat and text and refuses every caller-supplied override. |
365
373
  | `li_send_invitation` | `provider_id` required; `note` optional, max 300 exact characters; `confirm` optional, default false | First preview one exact target and note. The server verifies relationship, both invitation directions, and absence of an existing one-to-one chat. After exact approval, repeat the identical call with `confirm:true`; jitter completes before the final preflight and action reservation. |
366
374
  | `li_invitations_received` | `limit` integer 1-100, default 50; `cursor` optional | List one bounded page of received invitations. This read authorizes only the exact returned invitation IDs for a later accept. |
367
375
  | `li_accept_invitation` | `invitation_id` required; `confirm:true` required | Accept one exact currently pending received invitation after a fresh `li_invitations_received` read and approval. |
@@ -387,19 +395,23 @@ Use the exact tool names and argument keys below. Limits are optional.
387
395
  | `sd_withdrawal_batch_status` | `withdrawal_batch_id` required | Inspect or monitor one sweep: done and remaining, the per-day pace and this sweep's own daily allowance, the stop reason, parse failures, the exact people the exclusions protected, and the `approval_url` to hand the human. This read also authorizes a later exact cancel. |
388
396
  | `sd_withdrawal_batch_approve` | `withdrawal_batch_id` required; `confirm_token` required in the exact `sd-xxxx-xxxx-xxxx` form the human read off the approval page | Record the human's approval using the single-use code that authenticated page minted for them. An agent cannot mint, guess, or bypass that code. |
389
397
  | `sd_withdrawal_batch_cancel` | `withdrawal_batch_id` required; `approval_view_hash` required string or null exactly as inspected; `confirm:true` required | Revoke the approval and stop every withdrawal that has not started. It cannot recall one already submitted, and it cannot restore the three-week re-invite block for people already withdrawn. |
390
- | `wa_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200, and never more than five provider pages | Find an existing WhatsApp conversation and exact `chat_id`. With `search` it filters on `name`, `subject`, `id`, `provider_id` and `attendee_public_identifier`, which carries the phone number, and it normalizes digits so `+49 151 67609512`, `004915167609512` and `4915167609512@s.whatsapp.net` all match. The provider CANNOT filter a chat list, so SignalDash scans and filters: read `search.exhaustive` before you conclude anything, `search.limit_reached` to know whether the matches were truncated by `limit`, and `search.searched_fields` for what was actually compared. `false` means "not among the `scanned_chats` scanned", never "does not exist" -- continue from the returned `cursor`. When `started_from_cursor` is true the scan began mid-list, so even `exhaustive` says nothing about the chats before that cursor. A 1:1 chat has `name: null`, so search the phone number, not the person's name. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter`. |
391
- | `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. |
398
+ | `wa_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200, and never more than five provider pages | Find an existing WhatsApp conversation and exact `chat_id`. Direct chats expose a resolved contact in both `name` and `attendee_name` when available; groups retain their provider group `name` and return `attendee_name:null`. With `search` it filters on `name`, `subject`, `id`, `provider_id` and `attendee_public_identifier`, which carries the phone number, and it normalizes digits so `+49 151 67609512`, `004915167609512` and `4915167609512@s.whatsapp.net` all match. The provider CANNOT filter a chat list, so SignalDash scans and filters: read `search.exhaustive` before you conclude anything, `search.limit_reached` to know whether the matches were truncated by `limit`, and `search.searched_fields` for what was actually compared. `false` means "not among the `scanned_chats` scanned", never "does not exist" -- continue from the returned `cursor`. When `started_from_cursor` is true the scan began mid-list, so even `exhaustive` says nothing about the chats before that cursor. An unresolved 1:1 name remains null, so search the exact phone number instead of guessing. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter`. |
399
+ | `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. The response includes `current_watermark` for an exact compare-and-swap send. |
400
+ | `wa_mark_read` | `chat_id` required | Explicitly clear one WhatsApp chat's unread state. Reading never clears unread automatically. |
392
401
  | `wa_get_attachment` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each | Download one attachment of one message in a chat this account owns. Read the chat first: the exact `message_id` and `attachment_id` come from `wa_read_messages`. Returns the stored path on the SignalDash host, mimetype, byte size and sha256. |
393
402
  | `wa_transcribe_voice` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each; `backend` optional, exactly `gemini` or `whisper` | Turn one WhatsApp voice note into text through SignalDash instead of fetching provider bytes yourself. Always read the returned `backend`: `gemini` is the accurate default, `whisper-small` is the weak local fallback and mangles German with English terms mixed in, and a fallback also carries `fallback_reason`. Non-audio attachments are refused with `415 not_audio`; an unknown backend with `400 unknown_backend`; a transcription that exceeds its time limit returns `504 transcription_timeout` with the stored audio path. |
394
- | `wa_send_message` | `chat_id` required; `text` optional only when a file is attached, max 5000 characters; `attachments` optional array of up to 4 exact `{filename, content_type, content_base64}` files, at most 16 MiB per file and 16 MiB per message, types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, `text/csv`, `text/plain`, `application/json`, `application/zip`, xlsx | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. A file spends the same daily send budget and is recorded the same way as a text message; there is no separate attachment budget. A call carrying neither text nor an attachment is refused with `400 text_or_attachment_required`. Attachments are checked before anything is reserved, so a refusal costs no send: `400 unsupported_attachment_type`, `400 attachment_too_large`, `400 attachments_too_large`, `400 too_many_attachments`, `400 malformed_attachment_base64`, `400 invalid_attachment_filename`, and `413 request_too_large` when the whole body is too big to read. Nothing is ever truncated or dropped silently. The same caption with the same file is refused as `409 duplicate_send`; the same caption with a different file is a different message and goes through. If a send times out or the provider never confirms it, the message may still have been delivered: an identical retry is refused with `409 send_outcome_unknown`. Read the chat AGAIN, and ONLY if the message is genuinely absent, resend the identical payload with `confirm_resend:true`. The re-read is enforced, not advisory: a `confirm_resend` whose most recent read of that chat predates the failed attempt is refused with `428 reread_after_failed_send_required`, because a read taken before the attempt cannot show whether the message arrived. The send also re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after the read that authorized it, or with `502 thread_preflight_unavailable` if that re-read fails; in both cases nothing was sent and no budget was spent. A chat whose most recent read predates thread-change detection is refused with `428 read_before_send_required` until it is read again. LinkedIn messages carry text only. |
403
+ | `wa_send_message` | `chat_id` required; `text` optional only when a file is attached, max 5000 characters; `attachments` optional array of up to 4 exact `{filename, content_type, content_base64}` files, at most 16 MiB per file and 16 MiB per message, types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, `text/csv`, `text/plain`, `application/json`, `application/zip`, xlsx; `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean, default false | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. Own outbound additions do not invalidate the read; inbound additions or mutations return `new_messages`, `changed_kind`, and `current_watermark`. Pass `expected_watermark` to bind the send to the exact reviewed state and `mark_read:true` only when the approved workflow also calls for clearing unread after the confirmed send. A file spends the same daily send budget and is recorded the same way as a text message; there is no separate attachment budget. A call carrying neither text nor an attachment is refused with `400 text_or_attachment_required`. Attachments are checked before anything is reserved, so a refusal costs no send: `400 unsupported_attachment_type`, `400 attachment_too_large`, `400 attachments_too_large`, `400 too_many_attachments`, `400 malformed_attachment_base64`, `400 invalid_attachment_filename`, and `413 request_too_large` when the whole body is too big to read. Nothing is ever truncated or dropped silently. The same caption with the same file is refused as `409 duplicate_send`; the same caption with a different file is a different message and goes through. If a send times out or the provider never confirms it, the message may still have been delivered: an identical retry is refused with `409 send_outcome_unknown`. Read the chat AGAIN, and ONLY if the message is genuinely absent, resend the identical payload with `confirm_resend:true`. The re-read is enforced, not advisory: a `confirm_resend` whose most recent read of that chat predates the failed attempt is refused with `428 reread_after_failed_send_required`, because a read taken before the attempt cannot show whether the message arrived. A re-read failure remains `502 thread_preflight_unavailable`, and a legacy proof with no watermark remains `428 read_before_send_required`; neither costs send budget. LinkedIn messages carry text only. |
395
404
  | `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so read the chat again to confirm the message is gone. |
396
405
  | `wa_delete_messages` | `messages` required array of 1-200 exact `{chat_id, message_id}` objects | Retract several messages this account sent. Same ownership, budget and audit path as `wa_delete_message`, executed strictly one at a time with a pause between them, never in parallel. Always read the per-entry `ok`, `code` and `error`: a partial result is normal. Entries the batch never reached before its time limit come back with `skipped:true` and `code:batch_deadline`, and were not attempted; resend exactly those to resume. |
397
406
  | `email_list` | `limit` integer 1-100, default 20; `cursor` optional, max 4096 | List the newest message in each recent email thread and obtain `thread_id`. The response carries a `cursor`; pass it back to read the next page, and omit it for the first. |
398
407
  | `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. Everyone on `cc` counts as a participant, so a read authorizes them too. The newest messages are returned, not the oldest, so the people being replied to are always in the window. |
399
408
  | `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998, single line; `body` required, max 5000; `thread_id` optional, max 500 | Send one approved email to a participant in a recently read existing thread. Pass the `thread_id` you read to reply inside that thread; omit it only when starting a new one. A blank or oversized `thread_id` is refused with `400 invalid_request` rather than quietly starting a new thread beside the original. The same subject and body to the same person in the same thread is refused as `409 duplicate_send`; the same words in a different thread are a different message and go through. A subject containing a line break is refused with `400 invalid_request`, because a subject is one header line; a body with line breaks is normal and sends. If a send leaves this host and the provider never answers, the retry is refused as `409 send_outcome_unknown`: read the thread again, and only if the email is genuinely absent resend with `confirm_resend: true`. |
400
- | `li_my_posts` | `limit` default 10, max 50; `member_id` optional | Find the user's latest posts and post IDs. Omit `member_id` to use the connected user's own ID. |
401
- | `li_post_reactions` | `post_id` required; `limit` default 50, max 100 | Identify who reacted to one post and assess warm signals. A reaction does not authorize outreach. |
402
- | `li_post_comments` | `post_id` required; `limit` default 50, max 100 | Read comments and authors for one post; prioritize questions and substantive responses. |
409
+ | `li_my_posts` | `limit` default 10, max 50; `member_id` optional | Find the user's latest posts and exact `social_id` values. Omit `member_id` to use the connected user's own ID. Pass `social_id`, not a different numeric `id`, to the engagement tools. |
410
+ | `li_post_reactions` | `post_id` required; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of reactors. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Numeric ids are resolved against the user's own recent posts when possible, including ugcPost-backed multi-image posts. The provider total is reported when available and is never guessed from `li_my_posts`. A reaction does not authorize outreach. |
411
+ | `li_post_comments` | `post_id` required; `comment_id` optional exact parent id; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of comments, or pass `comment_id` to read replies to that exact parent. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Numeric ids are resolved against the user's own recent posts when possible. The provider total is reported when available and is never guessed from `li_my_posts`. |
412
+ | `li_reply_to_comment` | Manual path: `post_id`, `parent_comment_id`, `trigger_comment_id`, and `text` required; `expected_watermark` optional exact 64-character watermark. Secretary path: `secretary_receipt_id` alone. | Reply once to one exact inbound comment on this sender's own post after `li_post_comments`. SignalDash re-proves post ownership, the unchanged trigger, its author and parent, no own duplicate, sender generation, budget, and provider health immediately before writing. A 2xx is not success until readback finds exactly one matching own reply. |
413
+ | `li_delete_message` | `chat_id`, `message_id`, and `confirm:true` required | Remediate one exact own LinkedIn message within 60 minutes of sending. SignalDash proves account, exact chat, own authorship, timestamp eligibility, a separate remediation budget, and post-delete absence. This cannot undo delivery, reading, or notifications and never relaxes a send gate. |
414
+ | `li_delete_comment` | `post_id`, `comment_id`, and `confirm:true` required | Currently unavailable: the fixture-tested Unipile v2 wrapper is held at database capability state `untested` until live compatibility is proved against Federico's own removable comment. When enabled it proves own comment identity and readback. Deletion is remediation, not rollback. |
403
415
  | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `mentions` optional array of up to 20 exact `{name,profile_id}` objects; `attachments` optional array of up to 4 exact `{filename,content_type,content_base64}` images; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. A schedule is stored only with `publish:true` after approval of the exact text, time, mentions, images, and comment. |
404
416
  | `li_set_scheduled_post_first_comment` | `id` required UUID; `first_comment` required, max 1250; `confirm:true` required | Attach one exact approved first comment to a scheduled post. SignalDash publishes it through the same connected account after the post and never republishes the post if the comment fails. |
405
417
  | `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
@@ -411,6 +423,8 @@ Use the exact tool names and argument keys below. Limits are optional.
411
423
  Representative calls:
412
424
 
413
425
  ```text
426
+ sd_version({})
427
+ sd_secretary_latest({})
414
428
  li_list_chats({"limit":20})
415
429
  li_read_messages({"chat_id":"chat_li_7f3a","limit":20})
416
430
  li_send_message({"chat_id":"chat_li_7f3a","text":"Yes. I’ll send it this afternoon."})
@@ -452,8 +466,11 @@ email_list({"limit":20})
452
466
  email_read({"thread_id":"thread_email_c402","limit":30})
453
467
  email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study.","thread_id":"thread_email_c402"})
454
468
  li_my_posts({"limit":5})
455
- li_post_reactions({"post_id":"post_urn_8821","limit":50})
456
- li_post_comments({"post_id":"post_urn_8821","limit":50})
469
+ li_post_reactions({"post_id":"urn:li:activity:8821","limit":50})
470
+ li_post_comments({"post_id":"urn:li:activity:8821","limit":50})
471
+ li_reply_to_comment({"post_id":"urn:li:activity:8821","parent_comment_id":"comment-root","trigger_comment_id":"comment-root","text":"Thanks!","expected_watermark":"exact-64-character-watermark-from-the-read"})
472
+ li_delete_message({"chat_id":"chat_li_7f3a","message_id":"msg_li_5c71","confirm":true})
473
+ li_delete_comment({"post_id":"exact-v2-post-id","comment_id":"exact-own-comment-id","confirm":true})
457
474
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
458
475
  li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
459
476
  li_scheduled_posts({})
@@ -933,14 +950,17 @@ against the returned headline.
933
950
 
934
951
  Each result contains only:
935
952
 
936
- - `name`, `headline`, `public_id`, `profile_url`, and `connected_at`;
937
- - `already_in_contact`, based on exact recorded inbound, outbound, reply, or
938
- invitation history;
953
+ - `name`, `headline`, `public_id`, `member_id`, `profile_url`, and
954
+ `connected_at`; `member_id` is null only for a legacy row that has not yet
955
+ been refreshed or verified by chat search;
956
+ - `already_in_contact`, based on an exact known chat or recorded inbound,
957
+ outbound, reply, or invitation history;
939
958
  - the joined `contact_state`, `campaign_eligible`, and `blocking_reasons`.
940
959
 
941
- The join checks only the row's exact LinkedIn public identifier and canonical
942
- profile URL for this SignalDash user. It does not infer a person, merge aliases,
943
- or inspect another tenant. Search results are planning evidence, not permission
960
+ The join checks only the row's exact LinkedIn member id, public identifier,
961
+ canonical profile URL, and verified known-chat mapping for this SignalDash
962
+ user. It does not infer a person, merge aliases, or inspect another tenant.
963
+ Search results are planning evidence, not permission
944
964
  to message or invite anyone. A later action still requires its exact
945
965
  one-object read/preview, human review, approval, fresh preflight, and server
946
966
  guards. The executable invitation-batch design does not accept a search result
@@ -1084,19 +1104,20 @@ message you cannot yet schedule.
1084
1104
 
1085
1105
  ### 409 `thread_changed`
1086
1106
 
1087
- Meaning: the exact chat moved after the read that authorized the send. Both
1088
- `li_send_message` and `wa_send_message` re-read the thread immediately before
1089
- sending and refuse unless it is still the thread that was read. A read inside
1090
- the 30-minute window is not enough on its own: a thread that changed 29 minutes
1091
- ago satisfies the window and is still not the conversation the draft was
1092
- written against.
1107
+ Meaning: the counterparty added a message, an existing item changed or was
1108
+ deleted, or an explicit `expected_watermark` no longer matches. Both send tools
1109
+ return `new_messages`, `changed_kind`, and `current_watermark`. Additions made
1110
+ only by this account are classified as `own_outbound` and do not invalidate a
1111
+ send unless the caller supplied an explicit watermark that no longer matches.
1093
1112
 
1094
1113
  Comply:
1095
1114
 
1096
- 1. Do not resend the same text.
1097
- 2. Re-read the exact chat and read the new messages.
1098
- 3. Revise the draft against what the thread now says, and obtain approval again.
1099
- 4. Send once, promptly.
1115
+ 1. Inspect `new_messages` and `changed_kind`.
1116
+ 2. Revise the draft against the new context and obtain approval again when the
1117
+ change affects the approved text.
1118
+ 3. Pass the returned `current_watermark` as `expected_watermark` to bind the
1119
+ next attempt to exactly that reviewed state.
1120
+ 4. Send once, promptly. A second change returns another observable 409.
1100
1121
 
1101
1122
  A `502 thread_preflight_unavailable` on a send means that re-read itself failed,
1102
1123
  so whether the thread changed is unknown and nothing was sent. Retry the send
@@ -1106,11 +1127,10 @@ same fact: no answer about whether the conversation moved. In that case a `428
1106
1127
  read_before_send_required` on the next attempt is expected too, because a read
1107
1128
  of an unrecognised shape records no authorization.
1108
1129
 
1109
- Your own send moves the thread too, so two messages in a row are refused unless
1110
- you read the chat between them. This is not a glitch to work around: the second
1111
- message was drafted against a conversation that no longer exists, if only
1112
- because the first message is now in it. Read, confirm the first message landed
1113
- as intended, then send the second. If you meant one message, send one message.
1130
+ A confirmed send advances the stored proof. Distinct consecutive messages can
1131
+ therefore pass without a fresh read when the only movement is this account's
1132
+ own outbound. Every send still re-reads the provider thread, and a counterparty
1133
+ message or mutation still refuses before budget or provider write.
1114
1134
 
1115
1135
  ### 409 `duplicate_send`
1116
1136
 
@@ -1299,16 +1319,22 @@ User: "Who engaged with my last LinkedIn post, and what should I do?"
1299
1319
  ```
1300
1320
 
1301
1321
  2. Select the newest post by its provider timestamp, not by list position when
1302
- timestamps disagree. Record its exact `post_id`.
1303
- 3. Call:
1322
+ timestamps disagree. Record its exact `social_id`.
1323
+ 3. Call with the exact `social_id` from that post, not a different numeric `id`:
1304
1324
 
1305
1325
  ```text
1306
- li_post_reactions({"post_id":"post_urn_8821","limit":50})
1307
- li_post_comments({"post_id":"post_urn_8821","limit":50})
1326
+ li_post_reactions({"post_id":"urn:li:activity:8821","limit":50})
1327
+ li_post_comments({"post_id":"urn:li:activity:8821","limit":50})
1308
1328
  ```
1309
1329
 
1310
1330
  4. Group the result:
1311
1331
 
1332
+ Before grouping, inspect `completeness` on both responses. While its state
1333
+ is `incomplete`, repeat the call with `cursor: completeness.next_cursor`.
1334
+ A state of `unknown` is not a complete sweep; report that the provider did
1335
+ not prove completeness. Never substitute `li_my_posts.reaction_counter` as
1336
+ a fabricated result total.
1337
+
1312
1338
  - Direct questions in comments: respond publicly or draft a reply.
1313
1339
  - Existing contacts with substantive engagement: warm follow-up candidate,
1314
1340
  but no message without thread verification and approval.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: signaldash-safe-usage
3
- description: Safely search stored LinkedIn connections, inspect exact contact state and action budgets, read and send LinkedIn, WhatsApp, or email messages, and operate exact LinkedIn invitations or human-approved invitation batches through the SignalDash MCP without triggering account restrictions or contacting the wrong person. Use whenever an agent uses li_search_connections, sd_contact_state, sd_budget_status, li_list_chats, li_read_messages, li_send_message, li_send_invitation, li_invitations_received, li_accept_invitation, li_invitations_sent, li_withdraw_invitation, li_create_invitation_batch, li_get_invitation_batch, li_cancel_invitation_batch, wa_list_chats, wa_read_messages, wa_get_attachment, wa_transcribe_voice, wa_send_message, email_list, email_read, or email_send.
3
+ description: Safely search stored LinkedIn connections, inspect exact contact state and action budgets, approve or reject exact Secretary drafts, read, mark read, reply to comments, delete exact own content, and send LinkedIn, WhatsApp, or email messages through the SignalDash MCP without triggering account restrictions or contacting the wrong person. Use whenever an agent uses a SignalDash read, send, invitation, comment-reply, or delete tool.
4
4
  ---
5
5
 
6
6
  # Use SignalDash safely
@@ -23,6 +23,15 @@ Treat LinkedIn, WhatsApp, and email as human accounts, not bulk messaging APIs.
23
23
  Never infer a recipient from a partial name. Never send blind. Never retry a
24
24
  send after an ambiguous timeout without first reading the thread.
25
25
 
26
+ Reading is non-mutating. Clear unread only through an explicit `li_mark_read`
27
+ or `wa_mark_read` call, or an approved send with `mark_read:true`.
28
+
29
+ For a Secretary draft, approve only the exact `disposition_id` and
30
+ `payload_hash` returned by `sd_secretary_latest`. Pass the resulting
31
+ `secretary_receipt_id` alone to `li_send_message`. Never add or edit a chat,
32
+ recipient, text, attachment, watermark, or mark-read field beside the receipt.
33
+ On no, call `sd_secretary_reject` for that exact disposition and send nothing.
34
+
26
35
  ## Contact-state limits
27
36
 
28
37
  - `sd_contact_state` uses exact user-and-channel identifiers only. It does not
@@ -113,6 +122,15 @@ send after an ambiguous timeout without first reading the thread.
113
122
  cached records.
114
123
  - Stop all LinkedIn activity on any checkpoint, warning, restriction, unusual
115
124
  verification prompt, HTTP 403, or HTTP 429.
125
+ - Before `li_reply_to_comment`, read the exact own post comments, preserve the
126
+ exact parent, trigger and watermark, show the full reply text, and obtain
127
+ approval. The server readback, not the provider 2xx alone, proves success.
128
+ - Before `li_delete_message` or any future enabled `li_delete_comment`, read
129
+ and show the exact own object and obtain explicit approval. Deletion is
130
+ remediation after exposure, not rollback. It cannot undo a notification or
131
+ a recipient having read the content, and it never weakens any send gate.
132
+ - Treat `li_delete_comment` as unavailable while its capability reports
133
+ `untested`. Never change that flag merely to complete a task.
116
134
 
117
135
  LinkedIn detects behavior, not only API request frequency. Repeated copy,
118
136
  bursting sends, high profile-view volume, many ignored invitations, concurrent