@floomhq/signaldash 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.31.0 <invite-code>
105
+ npx -y @floomhq/signaldash@0.33.0 <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,22 @@ 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_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)`
171
+ - `li_delete_message(chat_id, message_id, confirm)`
172
+ - `li_delete_comment(post_id, comment_id, confirm)`
161
173
  - `li_draft_post(text, publish, scheduled_at?, mentions?, attachments?, first_comment?)`
162
174
  - `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
163
175
  - `li_scheduled_posts()`
@@ -169,6 +181,24 @@ SignalDash exposes:
169
181
  Every operation runs through the hosted SignalDash backend. Agents never
170
182
  receive the Unipile access key.
171
183
 
184
+ `li_reply_to_comment` acts only on an inbound comment on the authenticated
185
+ sender's own post. A preceding `li_post_comments` read records an exact
186
+ per-comment watermark. Immediately before the write, SignalDash re-proves the
187
+ post owner, sender generation, trigger author, unchanged trigger, parent
188
+ thread, absence of an own duplicate, action budget and sender health. A
189
+ provider 2xx remains `outcome_unknown` until bounded readback finds exactly one
190
+ own reply with the returned reply ID, expected parent and exact text.
191
+
192
+ LinkedIn deletion is remediation after exposure, not rollback. It never
193
+ relaxes a send or Secretary gate. `li_delete_message` is limited to the first
194
+ 60 minutes after the provider send timestamp and proves exact account, chat,
195
+ message and own authorship before using a separate remediation budget. It
196
+ marks success only after a readback proves the message absent. The v2
197
+ `li_delete_comment` wrapper is installed behind the database capability flag
198
+ `linkedin_comment_delete`, whose migration default is `untested`. It is not an
199
+ available live capability until an exact removable comment on Federico's own
200
+ post passes compatibility testing and the flag is separately enabled.
201
+
172
202
  A WhatsApp message can carry files. `wa_send_message` takes up to 4 exact
173
203
  `{filename, content_type, content_base64}` attachments, at most 16 MiB per file
174
204
  and 16 MiB per message, and accepts PNG, JPEG, WebP, GIF, PDF, CSV, plain text,
@@ -182,16 +212,23 @@ oversized attachment costs no send budget and never reaches the provider, and
182
212
  every refusal names the exact rule and the exact file that tripped it. Nothing
183
213
  is ever truncated or dropped silently. LinkedIn messages carry text only, and
184
214
  `li_send_message` refuses an `attachments` argument instead of ignoring it.
215
+ For a Secretary draft, `sd_secretary_approve` creates one 15-minute receipt
216
+ bound to the exact stored sender generation, recipient, thread, and text.
217
+ `li_send_message` then accepts that receipt ID alone, derives the payload from
218
+ storage, refuses caller-supplied overrides, and consumes the receipt atomically
219
+ with the action reservation. Rejection records a new human disposition and
220
+ sends nothing.
185
221
 
186
222
  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
223
+ thread immediately before sending. A counterparty message or a mutation after
224
+ the read refuses with `409 thread_changed`; an addition consisting only of this
225
+ account's own outbound messages does not invalidate the draft. The 409 includes
226
+ `new_messages`, `changed_kind`, and `current_watermark`, and both send tools
227
+ accept optional `expected_watermark` to bind a retry to the exact state the
228
+ caller reviewed. A 30-minute read window is satisfied just as comfortably by a
229
+ conversation that moved 29 minutes ago as by one nobody has touched, and a
230
+ reply that arrived in between is precisely when a draft stops being the right
231
+ thing to send. A chat whose most recent read predates this guard carries no hash
195
232
  and is refused with `428 read_before_send_required` until it is read again,
196
233
  rather than waved through on a timestamp that cannot answer the question. If the
197
234
  re-read itself fails, the send is refused with
@@ -419,6 +456,31 @@ resume exactly those. WhatsApp applies its own time and role limits to deleting
419
456
  for everyone and can answer successfully without removing anything, so re-read
420
457
  the chat to confirm.
421
458
 
459
+ ## Deploying server changes
460
+
461
+ A server change is not landed when only the files on disk changed. After the
462
+ change is merged to `main`, update the production checkout, restart the
463
+ long-running backend, and compare the live MCP result with the checked-out
464
+ commit:
465
+
466
+ ```bash
467
+ git pull --ff-only origin main
468
+ git rev-parse HEAD
469
+ sudo systemctl restart signaldash-api.service
470
+ sudo systemctl is-active signaldash-api.service
471
+ ```
472
+
473
+ Then call `sd_version({})` from an authenticated MCP session. Its `commit_sha`
474
+ must equal `git rev-parse HEAD`, and its `process_started_at` must be later than
475
+ the restart. The startup journal contains the same snapshot under the
476
+ `[signaldash] startup` prefix. A mismatch means the deployment is stale and the
477
+ server change is not shipped. `dirty_at_start` includes tracked changes,
478
+ untracked files, and submodule state present when the process started.
479
+
480
+ `SIGNALDASH_VERSION_ENABLED=0` removes the version route at runtime after a
481
+ service restart. It defaults to enabled and exposes no credential, account, or
482
+ message data.
483
+
422
484
  ## Releasing
423
485
 
424
486
  A release is a tag push. `.github/workflows/release.yml` runs `./release.sh` on
@@ -471,6 +533,32 @@ npm run check
471
533
  npm pack --dry-run
472
534
  ```
473
535
 
536
+ ### Offline LinkedIn archive import
537
+
538
+ The server-side importer reads LinkedIn export directories or ZIP files without
539
+ making provider requests. Preview the exact file fingerprints and row counts,
540
+ then apply the same archive to one bound LinkedIn sender generation:
541
+
542
+ ```bash
543
+ node scripts/import-linkedin-archive.mjs \
544
+ --archive /path/to/Complete_LinkedInDataExport \
545
+ --database server/sd-state-control.sqlite3 \
546
+ --sender-account <exact-sender-account-id> \
547
+ --owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
548
+ --dry-run
549
+
550
+ node scripts/import-linkedin-archive.mjs \
551
+ --archive /path/to/Complete_LinkedInDataExport \
552
+ --database server/sd-state-control.sqlite3 \
553
+ --sender-account <exact-sender-account-id> \
554
+ --owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
555
+ --apply
556
+ ```
557
+
558
+ The owner profile URL is required when `messages.csv` is present. It is the
559
+ typed identity used to classify inbound and outbound rows. A display name is
560
+ never used for that decision.
561
+
474
562
  `wa_transcribe_voice` runs on the SignalDash host and has two backends. The
475
563
  default, `gemini`, sends the audio to Vertex AI using that host's own
476
564
  credentials; it is the one that survives German speech with English technical
package/bin/sd.mjs CHANGED
@@ -298,18 +298,72 @@ export async function cmdClaim(provider, accountId, dependencies = {}) {
298
298
 
299
299
  // ---- MCP (stdio). Every tool proxies through the backend with the token. ----
300
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
+ },
301
354
  {
302
355
  name: "li_list_chats",
303
356
  ch: "li",
304
357
  action: "list_chats",
305
- 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.",
306
359
  inputSchema: {
307
360
  type: "object",
308
361
  properties: {
309
362
  limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
310
363
  cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
311
- 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." },
312
- 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." },
313
367
  },
314
368
  additionalProperties: false,
315
369
  },
@@ -318,7 +372,7 @@ const TOOLS = [
318
372
  name: "li_read_messages",
319
373
  ch: "li",
320
374
  action: "read",
321
- 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",
322
376
  inputSchema: {
323
377
  type: "object",
324
378
  properties: {
@@ -329,18 +383,34 @@ const TOOLS = [
329
383
  additionalProperties: false,
330
384
  },
331
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
+ },
332
400
  {
333
401
  name: "li_send_message",
334
402
  ch: "li",
335
403
  action: "send",
336
- 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.",
337
405
  inputSchema: {
338
406
  type: "object",
339
407
  properties: {
340
408
  chat_id: { type: "string", minLength: 1, maxLength: 500 },
341
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." },
342
413
  },
343
- required: ["chat_id", "text"],
344
414
  additionalProperties: false,
345
415
  },
346
416
  },
@@ -555,7 +625,7 @@ const TOOLS = [
555
625
  {
556
626
  name: "li_search_connections",
557
627
  path: "/li/connections/search",
558
- 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.",
559
629
  inputSchema: {
560
630
  type: "object",
561
631
  properties: {
@@ -969,13 +1039,13 @@ const TOOLS = [
969
1039
  name: "wa_list_chats",
970
1040
  ch: "wa",
971
1041
  action: "list_chats",
972
- 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.",
973
1043
  inputSchema: {
974
1044
  type: "object",
975
1045
  properties: {
976
1046
  limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
977
1047
  cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
978
- 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 case-insensitively on names and counterpart identifiers, including phone JIDs and @lids. 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, lid, or attendee name." },
979
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." },
980
1050
  },
981
1051
  additionalProperties: false,
@@ -985,7 +1055,7 @@ const TOOLS = [
985
1055
  name: "wa_read_messages",
986
1056
  ch: "wa",
987
1057
  action: "read",
988
- 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",
989
1059
  inputSchema: {
990
1060
  type: "object",
991
1061
  properties: {
@@ -996,6 +1066,20 @@ const TOOLS = [
996
1066
  additionalProperties: false,
997
1067
  },
998
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
+ },
999
1083
  {
1000
1084
  name: "wa_get_attachment",
1001
1085
  path: "/wa/get_attachment",
@@ -1031,7 +1115,7 @@ const TOOLS = [
1031
1115
  name: "wa_send_message",
1032
1116
  ch: "wa",
1033
1117
  action: "send",
1034
- 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.",
1035
1119
  inputSchema: {
1036
1120
  type: "object",
1037
1121
  properties: {
@@ -1064,6 +1148,8 @@ const TOOLS = [
1064
1148
  },
1065
1149
  },
1066
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." },
1067
1153
  },
1068
1154
  // `text` alone is no longer required: a document with no caption is a
1069
1155
  // legitimate message.
@@ -1188,7 +1274,7 @@ const TOOLS = [
1188
1274
  {
1189
1275
  name: "li_my_posts",
1190
1276
  path: "/li/posts",
1191
- 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.",
1192
1278
  inputSchema: {
1193
1279
  type: "object",
1194
1280
  properties: {
@@ -1201,12 +1287,13 @@ const TOOLS = [
1201
1287
  {
1202
1288
  name: "li_post_reactions",
1203
1289
  path: "/li/post_reactions",
1204
- 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.",
1205
1291
  inputSchema: {
1206
1292
  type: "object",
1207
1293
  properties: {
1208
1294
  post_id: { type: "string", minLength: 1, maxLength: 500 },
1209
1295
  limit: { type: "integer", minimum: 1, maximum: 100 },
1296
+ cursor: { type: "string", minLength: 1, maxLength: 4000 },
1210
1297
  },
1211
1298
  required: ["post_id"],
1212
1299
  additionalProperties: false,
@@ -1215,17 +1302,82 @@ const TOOLS = [
1215
1302
  {
1216
1303
  name: "li_post_comments",
1217
1304
  path: "/li/post_comments",
1218
- 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.",
1219
1306
  inputSchema: {
1220
1307
  type: "object",
1221
1308
  properties: {
1222
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." },
1223
1311
  limit: { type: "integer", minimum: 1, maximum: 100 },
1312
+ cursor: { type: "string", minLength: 1, maxLength: 4000 },
1224
1313
  },
1225
1314
  required: ["post_id"],
1226
1315
  additionalProperties: false,
1227
1316
  },
1228
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_like_comment",
1337
+ path: "/li/like_comment",
1338
+ description: "Like one exact inbound comment on the authenticated sender's own LinkedIn post. First read li_post_comments and preserve the resolved post, parent comment, exact comment, and returned signaldash_watermark. SignalDash re-proves post ownership, comment identity and content, absence of an own like, sender generation, budget, and provider safety immediately before writing. A provider 2xx is not success until bounded readback finds exactly one own like.",
1339
+ inputSchema: {
1340
+ type: "object",
1341
+ properties: {
1342
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1343
+ parent_comment_id: { type: "string", minLength: 1, maxLength: 500 },
1344
+ comment_id: { type: "string", minLength: 1, maxLength: 500 },
1345
+ expected_watermark: { type: "string", pattern: "^[0-9a-f]{64}$" },
1346
+ },
1347
+ required: ["post_id", "parent_comment_id", "comment_id"],
1348
+ additionalProperties: false,
1349
+ },
1350
+ },
1351
+ {
1352
+ name: "li_delete_message",
1353
+ path: "/li/delete_message",
1354
+ 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.",
1355
+ inputSchema: {
1356
+ type: "object",
1357
+ properties: {
1358
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1359
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1360
+ confirm: { type: "boolean", const: true },
1361
+ },
1362
+ required: ["chat_id", "message_id", "confirm"],
1363
+ additionalProperties: false,
1364
+ },
1365
+ },
1366
+ {
1367
+ name: "li_delete_comment",
1368
+ path: "/li/delete_comment",
1369
+ 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.",
1370
+ inputSchema: {
1371
+ type: "object",
1372
+ properties: {
1373
+ post_id: { type: "string", minLength: 1, maxLength: 500 },
1374
+ comment_id: { type: "string", minLength: 1, maxLength: 500 },
1375
+ confirm: { type: "boolean", const: true },
1376
+ },
1377
+ required: ["post_id", "comment_id", "confirm"],
1378
+ additionalProperties: false,
1379
+ },
1380
+ },
1229
1381
  {
1230
1382
  name: "li_draft_post",
1231
1383
  path: "/li/create_post",
@@ -1408,20 +1560,23 @@ export async function cmdSkill(dependencies = {}) {
1408
1560
  const { mkdirSync: mk, writeFileSync: wf, readFileSync: rf, existsSync: ex } = await import("node:fs");
1409
1561
  const { dirname, join } = await import("node:path");
1410
1562
  const here = dirname(fileURLToPath(import.meta.url));
1411
- const src = join(here, "..", "skills", "signaldash", "SKILL.md");
1412
- if (!ex(src)) { log("skill file not found in package"); process.exitCode = 1; return; }
1413
- const dest = join(homedir(), ".claude", "skills", "signaldash");
1414
- mk(dest, { recursive: true });
1563
+ const skills = ["signaldash", "secretary"];
1415
1564
  // The template pins the bootstrap command to a version placeholder rather
1416
1565
  // than a typed-in string: package.json is the only source of truth, so an
1417
1566
  // installed skill can never advertise a stale published release. Rendered
1418
1567
  // by the shared helper (lib/skill-template.cjs) so this substitution has a
1419
1568
  // single source of truth shared with the public skill page and the
1420
1569
  // server's own /skill and /skill.md routes.
1421
- const template = rf(src, "utf8");
1422
- wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
1423
- log(`Installed the SignalDash skill to ${dest}/SKILL.md`);
1424
- log("Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.");
1570
+ for (const name of skills) {
1571
+ const src = join(here, "..", "skills", name, "SKILL.md");
1572
+ if (!ex(src)) { log(`${name} skill file not found in package`); process.exitCode = 1; return; }
1573
+ const dest = join(homedir(), ".claude", "skills", name);
1574
+ mk(dest, { recursive: true });
1575
+ const template = rf(src, "utf8");
1576
+ wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
1577
+ log(`Installed the ${name === "signaldash" ? "SignalDash" : "Secretary"} skill to ${dest}/SKILL.md`);
1578
+ }
1579
+ log("Your agent now knows how to use SignalDash safely and render Secretary latest.");
1425
1580
  }
1426
1581
 
1427
1582
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
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,68 @@
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. In the same sentence, report the newest `shadow` counts and
23
+ its exact `stop_reason`; when `eligible_count` is zero, include the returned
24
+ `refusal_breakdown` counts so a zero is explained. Use these columns:
25
+
26
+ | Priority | When | Who | Channel | Exact inbound | Disposition | Action | Draft |
27
+ |---|---|---|---|---|---|---|---|
28
+
29
+ Keep the server order. Copy exact inbound text and draft text byte for byte,
30
+ using `<br>` only to render embedded line breaks inside a table cell. Escape
31
+ literal pipe characters for Markdown without changing their meaning. Never
32
+ invent an actor name, summary, disposition, recipient, or draft.
33
+
34
+ Use these priority labels for returned categories:
35
+
36
+ - `decisions_federico_owes`: Decision
37
+ - `drafts_waiting_for_yes`: Yes or no
38
+ - `shadow_since_last_asked`: Shadow
39
+ - `auto_sent_since_last_asked`: Auto-sent
40
+ - `leads_routed_to_beatriz`: Beatriz
41
+
42
+ After all returned rows, add exactly one final table row for `ignored`, even
43
+ when its total is zero. Put `Ignored` in Priority, the aggregate total and
44
+ reason counts in Action, and `-` in the other cells. When there are no returned
45
+ rows, this ignored row is still the only table row.
46
+
47
+ For a draft, display `approval_view.text` in Draft. For a shadow row, display
48
+ `shadow_view.exact_text` in Draft when it is non-null, byte for byte. A shadow
49
+ view is review evidence only and never an approval surface. A missing approval view is
50
+ not approval-ready. Ask Federico for yes or no only when the exact immutable
51
+ approval view is present. The voice evidence comes from
52
+ `approval_view.draft_voice`: measured LinkedIn corpus, median 7 words,
53
+ lowercase openers, zero observed em dashes, German for German input and English
54
+ otherwise. Never replace that evidence with a generic style prompt.
55
+
56
+ On an explicit yes to one exact displayed draft, call
57
+ `sd_secretary_approve` once with only its `disposition_id`, `payload_hash`, and
58
+ `confirm:true`. Then call `li_send_message` once with only the returned
59
+ `secretary_receipt_id`. Never pass a chat, recipient, text, attachment,
60
+ watermark, or mark-read field beside the receipt. The server derives the exact
61
+ payload and runs the existing read, freshness, duplicate, budget, queue,
62
+ warning, and outcome guards. Report the exact refusal when any guard stops it.
63
+
64
+ On an explicit no, call `sd_secretary_reject` once with only the displayed
65
+ `disposition_id` and `confirm:true`. This records the rejection and sends
66
+ nothing. Discussion, an edit, or approval of another draft is neither yes nor
67
+ no for the displayed payload. An edit requires a new current disposition and
68
+ 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,24 @@ 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 case-insensitively on `name`, `subject`, `id`, `provider_id`, `attendee_provider_id`, `attendee_public_identifier`, and `attendee_name`. The attendee identifiers can carry a phone JID or `@lid`, and digit normalization makes bare or formatted identifiers match their stored form, such as `+49 151 67609512`, `004915167609512`, `4915167609512@s.whatsapp.net`, or the bare digits from `202726935924973@lid`. 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. Search an exact phone number, lid, or known attendee name rather than 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_like_comment` | `post_id`, `parent_comment_id`, and `comment_id` required; `expected_watermark` optional exact 64-character watermark | Like once one exact inbound comment on this sender's own post after `li_post_comments`. SignalDash re-proves post ownership, the unchanged comment, its author and parent, no own like, sender generation, budget, and provider health immediately before writing. A 2xx is not success until bounded readback finds exactly one own like. |
414
+ | `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. |
415
+ | `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
416
  | `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
417
  | `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
418
  | `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
@@ -411,6 +424,8 @@ Use the exact tool names and argument keys below. Limits are optional.
411
424
  Representative calls:
412
425
 
413
426
  ```text
427
+ sd_version({})
428
+ sd_secretary_latest({})
414
429
  li_list_chats({"limit":20})
415
430
  li_read_messages({"chat_id":"chat_li_7f3a","limit":20})
416
431
  li_send_message({"chat_id":"chat_li_7f3a","text":"Yes. I’ll send it this afternoon."})
@@ -452,8 +467,12 @@ email_list({"limit":20})
452
467
  email_read({"thread_id":"thread_email_c402","limit":30})
453
468
  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
469
  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})
470
+ li_post_reactions({"post_id":"urn:li:activity:8821","limit":50})
471
+ li_post_comments({"post_id":"urn:li:activity:8821","limit":50})
472
+ 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"})
473
+ li_like_comment({"post_id":"urn:li:activity:8821","parent_comment_id":"comment-root","comment_id":"comment-root","expected_watermark":"exact-64-character-watermark-from-the-read"})
474
+ li_delete_message({"chat_id":"chat_li_7f3a","message_id":"msg_li_5c71","confirm":true})
475
+ li_delete_comment({"post_id":"exact-v2-post-id","comment_id":"exact-own-comment-id","confirm":true})
457
476
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
458
477
  li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
459
478
  li_scheduled_posts({})
@@ -933,14 +952,17 @@ against the returned headline.
933
952
 
934
953
  Each result contains only:
935
954
 
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;
955
+ - `name`, `headline`, `public_id`, `member_id`, `profile_url`, and
956
+ `connected_at`; `member_id` is null only for a legacy row that has not yet
957
+ been refreshed or verified by chat search;
958
+ - `already_in_contact`, based on an exact known chat or recorded inbound,
959
+ outbound, reply, or invitation history;
939
960
  - the joined `contact_state`, `campaign_eligible`, and `blocking_reasons`.
940
961
 
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
962
+ The join checks only the row's exact LinkedIn member id, public identifier,
963
+ canonical profile URL, and verified known-chat mapping for this SignalDash
964
+ user. It does not infer a person, merge aliases, or inspect another tenant.
965
+ Search results are planning evidence, not permission
944
966
  to message or invite anyone. A later action still requires its exact
945
967
  one-object read/preview, human review, approval, fresh preflight, and server
946
968
  guards. The executable invitation-batch design does not accept a search result
@@ -1084,19 +1106,20 @@ message you cannot yet schedule.
1084
1106
 
1085
1107
  ### 409 `thread_changed`
1086
1108
 
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.
1109
+ Meaning: the counterparty added a message, an existing item changed or was
1110
+ deleted, or an explicit `expected_watermark` no longer matches. Both send tools
1111
+ return `new_messages`, `changed_kind`, and `current_watermark`. Additions made
1112
+ only by this account are classified as `own_outbound` and do not invalidate a
1113
+ send unless the caller supplied an explicit watermark that no longer matches.
1093
1114
 
1094
1115
  Comply:
1095
1116
 
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.
1117
+ 1. Inspect `new_messages` and `changed_kind`.
1118
+ 2. Revise the draft against the new context and obtain approval again when the
1119
+ change affects the approved text.
1120
+ 3. Pass the returned `current_watermark` as `expected_watermark` to bind the
1121
+ next attempt to exactly that reviewed state.
1122
+ 4. Send once, promptly. A second change returns another observable 409.
1100
1123
 
1101
1124
  A `502 thread_preflight_unavailable` on a send means that re-read itself failed,
1102
1125
  so whether the thread changed is unknown and nothing was sent. Retry the send
@@ -1106,11 +1129,10 @@ same fact: no answer about whether the conversation moved. In that case a `428
1106
1129
  read_before_send_required` on the next attempt is expected too, because a read
1107
1130
  of an unrecognised shape records no authorization.
1108
1131
 
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.
1132
+ A confirmed send advances the stored proof. Distinct consecutive messages can
1133
+ therefore pass without a fresh read when the only movement is this account's
1134
+ own outbound. Every send still re-reads the provider thread, and a counterparty
1135
+ message or mutation still refuses before budget or provider write.
1114
1136
 
1115
1137
  ### 409 `duplicate_send`
1116
1138
 
@@ -1299,16 +1321,22 @@ User: "Who engaged with my last LinkedIn post, and what should I do?"
1299
1321
  ```
1300
1322
 
1301
1323
  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:
1324
+ timestamps disagree. Record its exact `social_id`.
1325
+ 3. Call with the exact `social_id` from that post, not a different numeric `id`:
1304
1326
 
1305
1327
  ```text
1306
- li_post_reactions({"post_id":"post_urn_8821","limit":50})
1307
- li_post_comments({"post_id":"post_urn_8821","limit":50})
1328
+ li_post_reactions({"post_id":"urn:li:activity:8821","limit":50})
1329
+ li_post_comments({"post_id":"urn:li:activity:8821","limit":50})
1308
1330
  ```
1309
1331
 
1310
1332
  4. Group the result:
1311
1333
 
1334
+ Before grouping, inspect `completeness` on both responses. While its state
1335
+ is `incomplete`, repeat the call with `cursor: completeness.next_cursor`.
1336
+ A state of `unknown` is not a complete sweep; report that the provider did
1337
+ not prove completeness. Never substitute `li_my_posts.reaction_counter` as
1338
+ a fabricated result total.
1339
+
1312
1340
  - Direct questions in comments: respond publicly or draft a reply.
1313
1341
  - Existing contacts with substantive engagement: warm follow-up candidate,
1314
1342
  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