@floomhq/signaldash 0.43.0 → 0.44.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
@@ -121,7 +121,7 @@ setup installs both from the same pinned npm package
121
121
  the human chose to execute:
122
122
 
123
123
  ```bash
124
- npx -y @floomhq/signaldash@0.43.0 <invite-code>
124
+ npx -y @floomhq/signaldash@0.44.0 <invite-code>
125
125
  ```
126
126
 
127
127
  Run that command in a terminal, not in an agent chat. Do not ask an agent to
@@ -201,7 +201,7 @@ timer, scheduler entry, Buffer item, or provider write.
201
201
  - `sd_withdrawal_batch_approve(withdrawal_batch_id, confirm_token)`
202
202
  - `sd_withdrawal_batch_cancel(withdrawal_batch_id, approval_view_hash, confirm)`
203
203
  - `wa_list_chats`
204
- - `wa_read_messages(chat_id)`
204
+ - `wa_read_messages(chat_id, limit?, cursor?)`
205
205
  - `wa_mark_read(chat_id)`
206
206
  - `wa_get_attachment(chat_id, message_id, attachment_id)`
207
207
  - `wa_transcribe_voice(chat_id, message_id, attachment_id)`
@@ -209,6 +209,42 @@ timer, scheduler entry, Buffer item, or provider write.
209
209
  - `wa_send_message(chat_id, text?, attachments?, expected_watermark?, mark_read?)` — confirmed sends clear unread by default; `mark_read:false` preserves unread.
210
210
  - `wa_delete_message(chat_id, message_id)`
211
211
  - `wa_delete_messages(messages)`
212
+
213
+ WhatsApp history is paginated, not limited to the newest 100 messages. Read
214
+ `wa_read_messages` with `limit:250`, then pass the exact returned `cursor`
215
+ unchanged to the same chat to read older pages. Each call fetches at most one
216
+ provider message page; there is no automatic bulk scan. Pace requests, set a
217
+ finite page/message budget, and stop on provider warnings, authentication
218
+ errors, 403/429, a repeated cursor, or an explicit null cursor. Empty pages
219
+ with a new cursor can still lead to more history.
220
+
221
+ The provider's `items` or `messages` envelope and cursor are preserved.
222
+ `history_pagination` reports `response_shape_supported`, requested limit, returned item count, next cursor,
223
+ `has_more` (null when unknown), `provider_exhausted`, and `cursor_advancing`.
224
+ Only an explicit null cursor on a recognized list proves provider exhaustion;
225
+ missing pagination or an unfamiliar envelope is unknown, never proof that a
226
+ message does not exist. Even exhausted provider history may be incomplete if
227
+ older history was never synchronized by the provider.
228
+
229
+ Continuation cursors must have been issued to this authenticated user, account
230
+ and exact chat. They live in a bounded in-memory registry for 60 minutes;
231
+ restart, expiry or an unissued/wrong-scope cursor requires a fresh first page
232
+ without cursor. At most one read per cursor runs concurrently. Successful
233
+ continuation consumes that receipt and registers the next cursor atomically;
234
+ provider/validation failures preserve it for retry. Registry bounds are 4,096
235
+ globally, 512 per user and 128 per account/chat, with no live-entry eviction.
236
+ Capacity returns an explicit error. Invalid provider cursors, mismatched
237
+ account/chat rows and unsupported historical response shapes are refused
238
+ without returning message contents.
239
+
240
+ Cursor pages return `history_page:true` and `current_watermark:null`. They
241
+ neither create nor refresh a send-read proof nor update reply/contact
242
+ observations or mark the chat read. Existing latest-page proofs are preserved.
243
+ Before sending, read the latest page again **without cursor**. Cursor support
244
+ does not change provider base URLs/API versions, send budgets, or account limits.
245
+ The default stays 30 messages. WhatsApp's explicit per-page maximum is 250,
246
+ matching the [provider's message endpoint](https://developer.unipile.com/reference/chatscontroller_listchatmessages.md);
247
+ LinkedIn/Instagram reads and chat-list caps remain 100.
212
248
  - `ig_list_chats(limit?, cursor?, search?, max_scan?, unread?)`
213
249
  - `ig_read_messages(chat_id, limit?)`
214
250
  - `ig_mark_read(chat_id)`
package/bin/sd.mjs CHANGED
@@ -1504,12 +1504,13 @@ const TOOLS = [
1504
1504
  name: "wa_read_messages",
1505
1505
  ch: "wa",
1506
1506
  action: "read",
1507
- 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",
1507
+ description: "Read one bounded page of messages and the resolved WhatsApp chat. Use limit up to 250 and the returned cursor to reach older history, one paced page at a time. Cursor pages never authorize sends or update reply observations; read without cursor immediately before sending. Missing history is not proof of absence. args: chat_id, limit, cursor",
1508
1508
  inputSchema: {
1509
1509
  type: "object",
1510
1510
  properties: {
1511
1511
  chat_id: { type: "string", minLength: 1, maxLength: 500 },
1512
- limit: { type: "integer", minimum: 1, maximum: 100 },
1512
+ limit: { type: "integer", minimum: 1, maximum: 250 },
1513
+ cursor: { type: "string", minLength: 1, maxLength: 4000, description: "Exact opaque cursor from the preceding page, unchanged. Omit for the latest page." },
1513
1514
  },
1514
1515
  required: ["chat_id"],
1515
1516
  additionalProperties: false,
@@ -1583,12 +1584,25 @@ const TOOLS = [
1583
1584
  name: "wa_send_message",
1584
1585
  ch: "wa",
1585
1586
  action: "send",
1586
- 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, zip and mp4/mov video. text is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. Defaults to mark_read:true after confirmed success; mark_read:false preserves unread state. 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.",
1587
+ description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments, mentions, 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, zip and mp4/mov video. text is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. Defaults to mark_read:true after confirmed success; mark_read:false preserves unread state. 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. GROUP @MENTIONS (WhatsApp groups only): put {{0}}, {{1}} ... in text where each person is tagged and pass mentions:[{profile_id}] in the same order, profile_id being the participant id from wa_read_messages (sender_id, e.g. 81983023665190@lid, or <phone>@s.whatsapp.net). WhatsApp renders each as @<digits> of that id and notifies the person; typing \"@Name\" as plain text notifies no one. Every {{i}} needs mentions[i] and every mention needs its {{i}}; a mention into a non-group chat is refused with mentions_require_group_chat. Mentions are part of the duplicate-send fingerprint.",
1587
1588
  inputSchema: {
1588
1589
  type: "object",
1589
1590
  properties: {
1590
1591
  chat_id: { type: "string", minLength: 1, maxLength: 500 },
1591
1592
  text: { type: "string", minLength: 1, maxLength: 5000 },
1593
+ mentions: {
1594
+ type: "array", minItems: 1, maxItems: 20,
1595
+ description: "WhatsApp groups only. mentions[i] is tagged where text has {{i}}.",
1596
+ items: {
1597
+ type: "object",
1598
+ properties: {
1599
+ profile_id: { type: "string", pattern: "^\\d+@(s\\.whatsapp\\.net|lid)$", description: "Participant JID, e.g. 81983023665190@lid." },
1600
+ name: { type: "string", maxLength: 200, description: "Optional label for your own readability; WhatsApp ignores it." },
1601
+ },
1602
+ required: ["profile_id"],
1603
+ additionalProperties: false,
1604
+ },
1605
+ },
1592
1606
  attachments: {
1593
1607
  type: "array", maxItems: 4,
1594
1608
  items: {
@@ -1868,7 +1882,7 @@ const TOOLS = [
1868
1882
  {
1869
1883
  name: "li_reply_to_comment",
1870
1884
  path: "/li/reply_to_comment",
1871
- 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. Optional mentions tag people in the reply: spell each person's name into text exactly once as a whole word and pass a matching mentions entry, exactly as li_draft_post does. LinkedIn already notifies the comment's author, so a mention is for tagging a THIRD person. 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 whose landed text equals text, so a tag whose display name does not render exactly as written is reported unverified rather than assumed sent. A Secretary approval uses secretary_receipt_id alone.",
1885
+ 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. Optional mentions tag people in the reply: spell each person's name into text exactly once as a whole word (or pass occurrence when the name repeats) and pass a matching mentions entry, exactly as li_draft_post does. LinkedIn already notifies the comment's author, so a mention is for tagging a THIRD person. 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 whose landed text equals text, so a tag whose display name does not render exactly as written is reported unverified rather than assumed sent. A Secretary approval uses secretary_receipt_id alone.",
1872
1886
  inputSchema: {
1873
1887
  type: "object",
1874
1888
  properties: {
@@ -1878,12 +1892,16 @@ const TOOLS = [
1878
1892
  text: { type: "string", minLength: 1, maxLength: 1250 },
1879
1893
  mentions: {
1880
1894
  type: "array", maxItems: 20,
1881
- description: "People to tag. Each name must appear exactly once in text as a whole word; SignalDash replaces that one occurrence with the provider's mention token. Use the person's exact LinkedIn display name, because the landed comment is verified against text character for character.",
1895
+ description: "People to tag. Each name must appear exactly once in text as a whole word, or, when it repeats, carry occurrence (1-based) naming which whole-word occurrence to tag; SignalDash replaces that one occurrence with the provider's mention token. Use the person's exact LinkedIn display name, because the landed comment is verified against text character for character.",
1882
1896
  items: {
1883
1897
  type: "object",
1884
1898
  properties: {
1885
1899
  name: { type: "string", minLength: 1, maxLength: 120 },
1886
1900
  profile_id: { type: "string", minLength: 1, maxLength: 250 },
1901
+ occurrence: {
1902
+ type: "integer", minimum: 1,
1903
+ description: "Only when name appears more than once in text as a whole word: the 1-based occurrence to tag (1 = first). Without it a repeated name is refused as mention_name_ambiguous; a value above the real count is refused as mention_occurrence_out_of_range.",
1904
+ },
1887
1905
  },
1888
1906
  required: ["name", "profile_id"],
1889
1907
  },
@@ -1958,7 +1976,7 @@ const TOOLS = [
1958
1976
  {
1959
1977
  name: "li_draft_post",
1960
1978
  path: "/li/create_post",
1961
- description: "Draft, publish, or schedule a LinkedIn post. A schedule with attachments requires an offset-qualified scheduled_at, a preview, then publish:true with confirm:true and the preview's single-use approval_hash bound to the exact text, time, mentions, attachments, first comment, and content-pipeline item. When the content pipeline is enabled, use an approved content_pipeline_id or preview one exact reasoned content_pipeline_override and repeat it with confirm:true plus its nested single-use approval_hash. Optional mentions, up to 20 attachments, and an account-owner-authored first_comment are preserved for the scheduled publish. An attachment is EITHER one of the four image types carrying content_base64, OR video/mp4 carrying content_url and no bytes: one absolute https URL streamed at publish time, at most 64 MiB, refused if it is not https, resolves to a private address, redirects to one, exceeds the ceiling, answers a different content type, or times out. A video attachment must be the only attachment on the post. Optional repost_post_id republishes an existing LinkedIn post with text as the commentary on top: it needs scheduled_at, is resolved read-only and shown in the preview (post_id, author, opening text), and always needs the preview's approval_hash with confirm:true; mentions and first_comment work as on any post.",
1979
+ description: "Draft, publish, or schedule a LinkedIn post. A schedule with attachments requires an offset-qualified scheduled_at, a preview, then publish:true with confirm:true and the preview's single-use approval_hash bound to the exact text, time, mentions, attachments, first comment, and content-pipeline item. When the content pipeline is enabled, use an approved content_pipeline_id or preview one exact reasoned content_pipeline_override and repeat it with confirm:true plus its nested single-use approval_hash. Optional mentions, up to 20 attachments, and an account-owner-authored first_comment are preserved for the scheduled publish. Each mention name must appear in text as a whole word exactly once; when it repeats (\"Apple ships the Apple II.\"), add occurrence (1-based) to tag that one occurrence, and the occurrence is part of what the approval_hash binds. An attachment is EITHER one of the four image types carrying content_base64, OR video/mp4 carrying content_url and no bytes: one absolute https URL streamed at publish time, at most 64 MiB, refused if it is not https, resolves to a private address, redirects to one, exceeds the ceiling, answers a different content type, or times out. A video attachment must be the only attachment on the post. Optional repost_post_id republishes an existing LinkedIn post with text as the commentary on top: it needs scheduled_at, is resolved read-only and shown in the preview (post_id, author, opening text), and always needs the preview's approval_hash with confirm:true; mentions and first_comment work as on any post.",
1962
1980
  inputSchema: {
1963
1981
  type: "object",
1964
1982
  properties: {
@@ -1987,6 +2005,10 @@ const TOOLS = [
1987
2005
  properties: {
1988
2006
  name: { type: "string", minLength: 1, maxLength: 120 },
1989
2007
  profile_id: { type: "string", minLength: 1, maxLength: 250 },
2008
+ occurrence: {
2009
+ type: "integer", minimum: 1,
2010
+ description: "Only when name appears more than once in text as a whole word: the 1-based occurrence to tag (1 = first). Without it a repeated name is refused as mention_name_ambiguous; a value above the real count is refused as mention_occurrence_out_of_range.",
2011
+ },
1990
2012
  },
1991
2013
  required: ["name", "profile_id"],
1992
2014
  },
@@ -2040,6 +2062,10 @@ const TOOLS = [
2040
2062
  properties: {
2041
2063
  name: { type: "string", minLength: 1, maxLength: 120 },
2042
2064
  profile_id: { type: "string", minLength: 1, maxLength: 250 },
2065
+ occurrence: {
2066
+ type: "integer", minimum: 1,
2067
+ description: "Only when name appears more than once in text as a whole word: the 1-based occurrence to tag (1 = first). Without it a repeated name is refused as mention_name_ambiguous; a value above the real count is refused as mention_occurrence_out_of_range.",
2068
+ },
2043
2069
  },
2044
2070
  required: ["name", "profile_id"],
2045
2071
  additionalProperties: false,
@@ -2140,6 +2166,10 @@ const TOOLS = [
2140
2166
  properties: {
2141
2167
  name: { type: "string", minLength: 1, maxLength: 120 },
2142
2168
  profile_id: { type: "string", minLength: 1, maxLength: 250 },
2169
+ occurrence: {
2170
+ type: "integer", minimum: 1,
2171
+ description: "Only when name appears more than once in text as a whole word: the 1-based occurrence to tag (1 = first). Without it a repeated name is refused as mention_name_ambiguous; a value above the real count is refused as mention_occurrence_out_of_range.",
2172
+ },
2143
2173
  },
2144
2174
  required: ["name", "profile_id"],
2145
2175
  additionalProperties: false,
@@ -2318,6 +2348,10 @@ const TOOLS = [
2318
2348
  properties: {
2319
2349
  name: { type: "string", minLength: 1, maxLength: 120 },
2320
2350
  profile_id: { type: "string", minLength: 1, maxLength: 250 },
2351
+ occurrence: {
2352
+ type: "integer", minimum: 1,
2353
+ description: "Only when name appears more than once in text as a whole word: the 1-based occurrence to tag (1 = first). Without it a repeated name is refused as mention_name_ambiguous; a value above the real count is refused as mention_occurrence_out_of_range.",
2354
+ },
2321
2355
  },
2322
2356
  required: ["name", "profile_id"],
2323
2357
  additionalProperties: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -446,12 +446,12 @@ Use the exact tool names and argument keys below. Limits are optional.
446
446
  | `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. |
447
447
  | `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. |
448
448
  | `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`. |
449
- | `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. |
449
+ | `wa_read_messages` | `chat_id` required; `limit` 1-250, default 30; optional `cursor` from the previous page, unchanged, max 4000 characters | Read one bounded WhatsApp page. Start without cursor; follow issued `history_pagination.next_cursor` with paced calls and a finite page/message budget. Historical pages return `history_page:true`, `current_watermark:null`, and never refresh send-read proofs or reply/contact observations. Cursors are scoped to this user/account/chat, expire after 60 minutes or restart, and permit one active read at a time; an invalid cursor requires a fresh first page. Inspect `has_more`, `provider_exhausted`, and `cursor_advancing`; missing pagination is unknown, not absence, and repeated cursors require stopping. Stop on provider warnings, authentication errors or 403/429. Read the latest page again without cursor before sending. |
450
450
  | `wa_mark_read` | `chat_id` required | Explicitly clear one WhatsApp chat's unread state. Reading never clears unread automatically. |
451
451
  | `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. |
452
452
  | `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. |
453
453
  | `wa_start_chat` | `member_ids` required array of 1-10 exact `@s.whatsapp.net` or `@lid` provider member IDs; `text` required exact first message; optional `subject` is the group name of a new group chat (2+ members, at most 100 characters, bound into the approval; there is no rename afterwards, so always pass it for a group); `confirm`, `approval_hash`, and `dry_run:true` apply only after preview | Start one direct or group chat under the same exact approval, live member resolution, protected-contact, duplicate-set, and readback contract as LinkedIn. If an `@lid` resolves to another canonical ID, repeat the preview with the returned `resolved_member_id`; SignalDash never substitutes an unapproved identity. A new chat spends one WhatsApp/email send action regardless of member count. An existing exact member-set chat is returned without sending. |
454
- | `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 true | 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:false` to preserve unread instead of clearing it 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. |
454
+ | `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; `mentions` optional array of 1 to 20 `{profile_id, name?}` (WhatsApp groups only; `profile_id` matches `^\d+@(s\.whatsapp\.net|lid)$`, `name` is ignored); `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean, default true | 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:false` to preserve unread instead of clearing it 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. Group @mentions: typing `@Name` notifies nobody. Put `{{0}}`, `{{1}}` ... in `text` where each person is tagged and pass `mentions:[{profile_id}]` in the same order, `profile_id` being the participant id from `wa_read_messages` (`sender_id`, e.g. `81983023665190@lid`, or `<phone>@s.whatsapp.net`); WhatsApp renders each as `@<digits>` and notifies that person. Every `{{i}}` needs `mentions[i]` and every mention needs its `{{i}}` (`400 invalid_mentions`); a mention into a chat that is not a group is refused with `400 mentions_require_group_chat`. Mentions are part of the duplicate fingerprint: the same words tagging someone else are a different message. |
455
455
  | `ig_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200; `unread` optional boolean | Find an existing Instagram DM conversation and its exact `chat_id` on the Instagram account bound to this user. A search filters over a bounded scan: read `search.exhaustive` before concluding anything, and continue from `cursor`. |
456
456
  | `ig_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Read one Instagram DM thread before summarizing, drafting, or sending. Returns `current_watermark`. Reading never clears unread. |
457
457
  | `ig_mark_read` | `chat_id` required | Explicitly clear one Instagram chat's unread state. |
@@ -465,11 +465,11 @@ Use the exact tool names and argument keys below. Limits are optional.
465
465
  | `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. |
466
466
  | `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. |
467
467
  | `li_post_comments` | `post_id` required; `comment_id` optional exact parent id; `resolve_reply_state` optional boolean, not combinable with `comment_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. Pass `resolve_reply_state: true` before replying to anything: each comment then carries `reply_state.replied_by_me`. Skip every comment whose `replied_by_me` is `true`, and skip every comment whose `replied_by_me` is `null`, which means unknown and never means nobody replied. Only `no_replies` and `answered_by_others` are proven unanswered. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Also inspect `reply_resolution.state`; `partial` or `aborted` means some comments were never resolved. 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`. |
468
- | `li_reply_to_comment` | Manual path: `post_id`, `parent_comment_id`, `trigger_comment_id`, and `text` required; `mentions` optional array of `{name, profile_id}`; `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. Each mention name must appear exactly once in `text` as a whole word; SignalDash swaps that occurrence for the provider's token, and every tagged person is checked against suppression and protection exactly like the person being replied to. Because the landed comment is compared against `text` character for character, use the person's exact LinkedIn display name or the write is reported unverified. |
468
+ | `li_reply_to_comment` | Manual path: `post_id`, `parent_comment_id`, `trigger_comment_id`, and `text` required; `mentions` optional array of `{name, profile_id, occurrence?}`; `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. Each mention name must appear exactly once in `text` as a whole word, or carry a 1-based `occurrence` when it repeats; SignalDash swaps that occurrence for the provider's token, and every tagged person is checked against suppression and protection exactly like the person being replied to. Because the landed comment is compared against `text` character for character, use the person's exact LinkedIn display name or the write is reported unverified. |
469
469
  | `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. |
470
470
  | `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 the provider's post-delete state. Success requires `deleted:1` or a genuine 404. A present row or failed readback returns `502 deleted_unconfirmed`, locks the sender, and is never retried automatically. This cannot undo delivery, reading, or notifications and never relaxes a send gate. |
471
471
  | `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. |
472
- | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `content_pipeline_id` normally required for scheduling while that user's pipeline is enabled; `content_pipeline_override` optional exact `{reason,confirm?,approval_hash?}`; `mentions` optional array of up to 25 exact `{name,profile_id}` objects; `attachments` optional array of up to 20 exact objects, each either an image as `{filename,content_type,content_base64}` (max 5 MiB each and 12 MiB total) or one video as `{filename,content_type:"video/mp4",content_url,content_sha256?}`; `first_comment` optional, max 1250; `repost_post_id` optional, scheduled only, the numeric activity id or `urn:li:activity|ugcPost|share:<digits>` of a post to repost with `text` as commentary; scheduled attachments and every repost require top-level `confirm:true` plus the preview's `approval_hash` | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. A scheduled attachment preview returns a single-use approval bound to the exact text, time, mentions, attachment filenames/types plus image-byte digests or video URL/digest, first comment, and pipeline item. A repost preview resolves the target read-only and returns `repost` (post_id, social_id, author, share_url, text_excerpt) so the human approves which post is republished; the approval is bound to that post id. An enabled pipeline normally runs its fact, exact-text-and-attachment, shared-calendar, frequency, and breathing preflight. For one deliberate exception, preview the exact payload with `{reason}`, show it to the human, then repeat it with nested `confirm:true` and the returned single-use payload-bound `approval_hash`. The audit record preserves the reason, preview ID, and approval time; every non-pipeline provider safety guard remains active. |
472
+ | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `content_pipeline_id` normally required for scheduling while that user's pipeline is enabled; `content_pipeline_override` optional exact `{reason,confirm?,approval_hash?}`; `mentions` optional array of up to 25 exact `{name,profile_id,occurrence?}` objects, where `occurrence` (1-based) picks one whole-word repeat of a name that appears more than once and is bound into the approval; `attachments` optional array of up to 20 exact objects, each either an image as `{filename,content_type,content_base64}` (max 5 MiB each and 12 MiB total) or one video as `{filename,content_type:"video/mp4",content_url,content_sha256?}`; `first_comment` optional, max 1250; `repost_post_id` optional, scheduled only, the numeric activity id or `urn:li:activity|ugcPost|share:<digits>` of a post to repost with `text` as commentary; scheduled attachments and every repost require top-level `confirm:true` plus the preview's `approval_hash` | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. A scheduled attachment preview returns a single-use approval bound to the exact text, time, mentions, attachment filenames/types plus image-byte digests or video URL/digest, first comment, and pipeline item. A repost preview resolves the target read-only and returns `repost` (post_id, social_id, author, share_url, text_excerpt) so the human approves which post is republished; the approval is bound to that post id. An enabled pipeline normally runs its fact, exact-text-and-attachment, shared-calendar, frequency, and breathing preflight. For one deliberate exception, preview the exact payload with `{reason}`, show it to the human, then repeat it with nested `confirm:true` and the returned single-use payload-bound `approval_hash`. The audit record preserves the reason, preview ID, and approval time; every non-pipeline provider safety guard remains active. |
473
473
  | `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. |
474
474
  | `li_save_post_draft` | `text` required, max 3000; `mentions` optional up to 25; `attachments` optional up to 20, images by `content_base64` (max 5 MiB each and 12 MiB total) or one video/mp4 by `content_url`; `first_comment` optional, max 1250; `source_key` optional tenant-scoped idempotency key, max 256; `metadata` optional bounded structured source/editorial context | Persist one arbitrary LinkedIn draft without scheduling or publishing. Exact ordered image bytes and ordinary duplicate drafts are retained. An identical `source_key` replay returns the existing draft; changed content under that key is refused. Tenant limits are 1,000 drafts and 100 MiB decoded attachment bytes. No provider call or action budget is used. |
475
475
  | `li_post_drafts` | optional `review_ttl_hours` (`1` or `24`) | List durable SignalDash drafts with attachment metadata, never base64, and return a fresh one-use calendar link. The default handoff and review session last 60 minutes; pass `24` for a full day. The calendar uses the scheduled-post card design for unscheduled SignalDash and Buffer drafts, with a visible `DRAFT` tag. Its review session saves copy, notes, and up to four private feedback images inline without reloading the calendar. Reviewers can explicitly replace the post's current images with those uploads; unchecked uploads remain private references. Each SignalDash draft card also displays its exact version and next verified free 08:00 UTC slot. `Approve & schedule` rechecks the complete SignalDash and Buffer calendar, then atomically moves the exact draft into that slot without a provider call. Stored drafts remain separate from scheduled posts until this explicit approval succeeds. |
@@ -539,6 +539,7 @@ wa_transcribe_voice({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71","attach
539
539
  wa_start_chat({"member_ids":["4915111111111@s.whatsapp.net","202700000000000@lid"],"subject":"Amina <> Jo","text":"Amina, meet Jo. I think you two have a lot to compare."})
540
540
  wa_send_message({"chat_id":"chat_wa_91b2","text":"16:30 works. See you then."})
541
541
  wa_send_message({"chat_id":"chat_wa_91b2","text":"Q3 numbers attached.","attachments":[{"filename":"q3-arr.csv","content_type":"text/csv","content_base64":"<base64>"}]})
542
+ wa_send_message({"chat_id":"chat_wa_group","text":"{{0}} can you confirm Thursday?","mentions":[{"profile_id":"81983023665190@lid"}]})
542
543
  wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
543
544
  wa_delete_messages({"messages":[{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"},{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c72"}]})
544
545
  email_list({"limit":20})