@floomhq/signaldash 0.40.0 → 0.42.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
@@ -23,6 +23,7 @@ the human to complete. The equivalent manual sequence is:
23
23
  npx -y @floomhq/signaldash login <invite-code>
24
24
  npx -y @floomhq/signaldash connect linkedin
25
25
  npx -y @floomhq/signaldash connect whatsapp
26
+ npx -y @floomhq/signaldash connect instagram # optional
26
27
  npx -y @floomhq/signaldash connect email
27
28
  ```
28
29
 
@@ -120,7 +121,7 @@ setup installs both from the same pinned npm package
120
121
  the human chose to execute:
121
122
 
122
123
  ```bash
123
- npx -y @floomhq/signaldash@0.40.0 <invite-code>
124
+ npx -y @floomhq/signaldash@0.42.0 <invite-code>
124
125
  ```
125
126
 
126
127
  Run that command in a terminal, not in an agent chat. Do not ask an agent to
@@ -208,6 +209,11 @@ timer, scheduler entry, Buffer item, or provider write.
208
209
  - `wa_send_message(chat_id, text?, attachments?, expected_watermark?, mark_read?)` — confirmed sends clear unread by default; `mark_read:false` preserves unread.
209
210
  - `wa_delete_message(chat_id, message_id)`
210
211
  - `wa_delete_messages(messages)`
212
+ - `ig_list_chats(limit?, cursor?, search?, max_scan?, unread?)`
213
+ - `ig_read_messages(chat_id, limit?)`
214
+ - `ig_mark_read(chat_id)`
215
+ - `ig_get_attachment(chat_id, message_id, attachment_id)`
216
+ - `ig_send_message(chat_id, text, expected_watermark?, mark_read?)`: text only, same read-before-send guards as WhatsApp, Instagram budget below.
211
217
  - `email_list(limit)`
212
218
  - `email_read(thread_id, limit)`
213
219
  - `email_send(to, subject, body)`
@@ -218,6 +224,7 @@ timer, scheduler entry, Buffer item, or provider write.
218
224
  - `li_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)`
219
225
  - `li_delete_message(chat_id, message_id, confirm)`
220
226
  - `li_delete_comment(post_id, comment_id, confirm)`
227
+ - `li_delete_post(post_id, expected_text_prefix, confirm)` (own post only, bound account only, capability `linkedin_post_delete`)
221
228
  - `li_draft_post(text, publish, scheduled_at?, content_pipeline_id?, content_pipeline_override?, mentions?, attachments?, first_comment?, confirm?, approval_hash?)`
222
229
  - `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
223
230
  - `li_save_post_draft(text, mentions?, attachments?, first_comment?, source_key?, metadata?)`
@@ -727,6 +734,33 @@ workflow around that runtime control:
727
734
  the conservative reading is the safe one) and clears at the next UTC midnight;
728
735
  LinkedIn, WhatsApp/email, and delete ledgers are keyed to the stable logical
729
736
  user or sender rather than multiplied by live bearer sessions.
737
+ - Instagram DMs have their own budget per connected Instagram account, never
738
+ shared with LinkedIn or with the WhatsApp/email lane. Defaults: 50 sends per
739
+ UTC day, of which at most 15 may open a new conversation; 250 sends and 60
740
+ new conversations per UTC week (Monday 00:00 UTC); 10 sends per rolling hour;
741
+ at least 45 seconds between sends plus 0 to 30 seconds of random jitter. A
742
+ send counts as a new conversation when the page of the thread that was read
743
+ holds no message from the account itself (a cold DM, or a first answer to a
744
+ message request); every other send is a reply. A freshly connected account
745
+ warms up over 14 days: the first 24 hours allow 10 replies and no new
746
+ conversation, days 1 to 6 allow 20 sends and 5 new conversations, days 7 to
747
+ 13 allow 35 sends and 10 new conversations. A provider 403 or 429 on a send
748
+ pauses Instagram sends for that account for 24 hours. `sd_budget_status`
749
+ reports all of it under `instagram`. Per-account overrides go in
750
+ `SIGNALDASH_INSTAGRAM_BUDGET_OVERRIDES`, a JSON object keyed by the Unipile
751
+ account id with any of `daily_total`, `new_chats_daily`, `weekly_total`,
752
+ `new_chats_weekly`, `hourly_total`, `min_gap_ms`, `gap_jitter_ms` and
753
+ `warmup` (boolean). Overrides can raise or lower a limit but never past the
754
+ hard ceilings of 100 per day, 10 per hour, 40 new conversations per day, 500
755
+ per week, 200 new conversations per week, and a 20 second minimum gap.
756
+ Sources for the numbers: Unipile provider limits (at most 100 actions per day
757
+ and 10 per hour per Instagram account, start new accounts lower,
758
+ https://developer.unipile.com/docs/provider-limits-and-restrictions), Wave
759
+ (50 DMs per day including follow-ups as the common ceiling, 10 per day for a
760
+ new account, https://www.usewave.co/blog/instagram-dm-limits) and Celestia
761
+ Leads (20 to 35 cold DMs per day once warmed, under 160 per week, pause 24 to
762
+ 72 hours after an action block,
763
+ https://celestialeads.com/blog/instagram-dm-automation-guide).
730
764
  - Read the exact thread before every send.
731
765
  - Preview an exact invitation target and note before sending it.
732
766
  - List the exact received or sent invitation before accepting or withdrawing it.
package/bin/sd.mjs CHANGED
@@ -278,8 +278,8 @@ export async function cmdConnect(provider, dependencies = {}) {
278
278
  const now = dependencies.now || Date.now;
279
279
  const log = dependencies.log || console.log;
280
280
  const error = dependencies.error || console.error;
281
- if (!["linkedin", "whatsapp", "email"].includes(provider)) {
282
- error("usage: signaldash connect linkedin|whatsapp|email");
281
+ if (!["linkedin", "whatsapp", "instagram", "email"].includes(provider)) {
282
+ error("usage: signaldash connect linkedin|whatsapp|instagram|email");
283
283
  process.exitCode = 1;
284
284
  return;
285
285
  }
@@ -864,12 +864,14 @@ const TOOLS = [
864
864
  {
865
865
  name: "li_send_invitation",
866
866
  path: "/li/send_invitation",
867
- description: "Preview or send one exact LinkedIn invitation. First call without confirm, obtain approval, then repeat the exact provider_id and note with confirm:true.",
867
+ description: "Preview or send one exact LinkedIn invitation. First call without confirm, obtain approval, then repeat the exact provider_id, note, follow_up and note_mode with confirm:true. DEFAULT: a note is NOT put on the invitation (free accounts get notes refused); the invitation goes out bare and the note text is sent once as the first message after the person is proven to have accepted. Set note_mode \"inline\" only when the note must ride on the invitation itself. The preview shows the exact follow-up text; list or cancel pending follow-ups with sd_invitation_followups / sd_invitation_followup_cancel.",
868
868
  inputSchema: {
869
869
  type: "object",
870
870
  properties: {
871
871
  provider_id: { type: "string", minLength: 1, maxLength: 500 },
872
- note: { type: "string", maxLength: 300 },
872
+ note: { type: "string", maxLength: 300, description: "With the default note_mode follow_up this text becomes the after-acceptance message; with note_mode inline it rides on the invitation." },
873
+ follow_up: { type: "string", minLength: 1, maxLength: 5000, description: "Exact message to send once after acceptance. Use instead of note (default mode), or alongside an inline note." },
874
+ note_mode: { type: "string", enum: ["follow_up", "inline"], default: "follow_up" },
873
875
  confirm: { type: "boolean", default: false },
874
876
  },
875
877
  required: ["provider_id"],
@@ -939,7 +941,7 @@ const TOOLS = [
939
941
  properties: {
940
942
  channel: {
941
943
  type: "string",
942
- enum: ["linkedin", "whatsapp", "email"],
944
+ enum: ["linkedin", "whatsapp", "instagram", "email"],
943
945
  },
944
946
  identifiers: {
945
947
  type: "array",
@@ -990,7 +992,7 @@ const TOOLS = [
990
992
  {
991
993
  name: "sd_budget_status",
992
994
  path: "/sd/budget_status",
993
- description: "Read authoritative SignalDash LinkedIn attempt capacity for the invitation, existing-thread message, and own-post engagement daily lanes, the aggregate emergency brake, and the unchanged weekly invitation cap.",
995
+ description: "Read authoritative SignalDash LinkedIn attempt capacity for the invitation, existing-thread message, and own-post engagement daily lanes, the aggregate emergency brake, and the unchanged weekly invitation cap. When Instagram is connected, `instagram` reports that account's own DM budget: daily, weekly and hourly totals, the separate new-conversation caps, the warm-up stage, the next allowed send time and any provider pause.",
994
996
  inputSchema: {
995
997
  type: "object",
996
998
  properties: {},
@@ -1677,6 +1679,85 @@ const TOOLS = [
1677
1679
  additionalProperties: false,
1678
1680
  },
1679
1681
  },
1682
+ {
1683
+ name: "ig_list_chats",
1684
+ ch: "ig",
1685
+ action: "list_chats",
1686
+ description: "List or search the Instagram DM chats of the Instagram account bound to you. `unread:true` asks the provider for unread chats only. A search filters over a bounded scan of chat pages and reports `scanned_chats` and `exhaustive`, so a miss is never a proven absence.",
1687
+ inputSchema: {
1688
+ type: "object",
1689
+ properties: {
1690
+ limit: { type: "integer", minimum: 1, maximum: 100, description: "How many chats to return. With `search`, how many MATCHES to return." },
1691
+ cursor: { type: "string", maxLength: 4000, description: "Continue from a previous page or a previous search." },
1692
+ search: { type: "string", minLength: 1, maxLength: 200, description: "Filter chats case-insensitively on names, usernames and counterpart identifiers." },
1693
+ 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." },
1694
+ unread: { type: "boolean", description: "Only unread chats." },
1695
+ },
1696
+ additionalProperties: false,
1697
+ },
1698
+ },
1699
+ {
1700
+ name: "ig_read_messages",
1701
+ ch: "ig",
1702
+ action: "read",
1703
+ description: "Read messages and the resolved chat in one Instagram DM thread of your bound Instagram account. Returns current_watermark for a later ig_send_message. Reading never marks the chat read. args: chat_id",
1704
+ inputSchema: {
1705
+ type: "object",
1706
+ properties: {
1707
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1708
+ limit: { type: "integer", minimum: 1, maximum: 100 },
1709
+ },
1710
+ required: ["chat_id"],
1711
+ additionalProperties: false,
1712
+ },
1713
+ },
1714
+ {
1715
+ name: "ig_mark_read",
1716
+ ch: "ig",
1717
+ action: "mark_read",
1718
+ description: "Explicitly clear the unread state of one Instagram DM chat of your bound account. Reading a thread never clears it automatically.",
1719
+ inputSchema: {
1720
+ type: "object",
1721
+ properties: {
1722
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1723
+ },
1724
+ required: ["chat_id"],
1725
+ additionalProperties: false,
1726
+ },
1727
+ },
1728
+ {
1729
+ name: "ig_get_attachment",
1730
+ path: "/ig/get_attachment",
1731
+ description: "Download one attachment of one Instagram DM message in a chat your bound Instagram account owns and store it on the SignalDash host. Read the chat first to obtain the exact message_id and attachment_id. Returns the stored path, mimetype, byte size and sha256.",
1732
+ inputSchema: {
1733
+ type: "object",
1734
+ properties: {
1735
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1736
+ message_id: { type: "string", minLength: 1, maxLength: 500 },
1737
+ attachment_id: { type: "string", minLength: 1, maxLength: 500 },
1738
+ },
1739
+ required: ["chat_id", "message_id", "attachment_id"],
1740
+ additionalProperties: false,
1741
+ },
1742
+ },
1743
+ {
1744
+ name: "ig_send_message",
1745
+ ch: "ig",
1746
+ action: "send",
1747
+ description: "Send one Instagram DM (text only) in an existing chat of your bound Instagram account. Same guards as wa_send_message: read the chat first with ig_read_messages, a changed thread returns 409 thread_changed, an identical message is refused, and an unconfirmed send blocks its retry until you re-read and pass confirm_resend:true. Instagram has its own conservative per-account budget: a send into a thread with no message of yours on the page you read counts as a NEW conversation (much smaller cap, none at all in the first 24 hours after connecting), every other send is a reply; daily, weekly and hourly totals apply to both, sends are spaced at least 45 seconds apart plus random jitter, a freshly connected account warms up over 14 days, and a provider 403/429 pauses Instagram sends for 24 hours. A refusal names the exact cap and when it frees up; check sd_budget_status first. args: chat_id, text, expected_watermark, mark_read",
1748
+ inputSchema: {
1749
+ type: "object",
1750
+ properties: {
1751
+ chat_id: { type: "string", minLength: 1, maxLength: 500 },
1752
+ text: { type: "string", minLength: 1, maxLength: 5000 },
1753
+ confirm_resend: { type: "boolean", const: true },
1754
+ 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." },
1755
+ mark_read: { type: "boolean", default: true, description: "Clear unread after confirmed success; false preserves unread state." },
1756
+ },
1757
+ required: ["chat_id", "text"],
1758
+ additionalProperties: false,
1759
+ },
1760
+ },
1680
1761
  {
1681
1762
  name: "email_list",
1682
1763
  path: "/email/list",
@@ -1859,6 +1940,21 @@ const TOOLS = [
1859
1940
  additionalProperties: false,
1860
1941
  },
1861
1942
  },
1943
+ {
1944
+ name: "li_delete_post",
1945
+ path: "/li/delete_post",
1946
+ description: "Delete ONE LinkedIn post authored by this authenticated account. Irreversible; disabled unless the operator enables the linkedin_post_delete capability. Acts only as the account bound to this SignalDash session. Requires the exact social_id from li_my_posts (urn:li:activity:<digits>), expected_text_prefix (the first 10-500 characters of the post text, exactly) and confirm:true. Before deleting, SignalDash reads the post with the same account and refuses unless the author is this sender (post_not_own), it is not a repost, and the text starts with expected_text_prefix (text_prefix_mismatch). Returns the post evidence and a post-delete readback; a replay of the same post is refused as duplicate_delete. Deletion is remediation, not rollback.",
1947
+ inputSchema: {
1948
+ type: "object",
1949
+ properties: {
1950
+ post_id: { type: "string", pattern: "^urn:li:activity:[0-9]{1,30}$", maxLength: 60 },
1951
+ expected_text_prefix: { type: "string", minLength: 10, maxLength: 500 },
1952
+ confirm: { type: "boolean", const: true },
1953
+ },
1954
+ required: ["post_id", "expected_text_prefix", "confirm"],
1955
+ additionalProperties: false,
1956
+ },
1957
+ },
1862
1958
  {
1863
1959
  name: "li_draft_post",
1864
1960
  path: "/li/create_post",
@@ -2325,6 +2421,48 @@ const TOOLS = [
2325
2421
  additionalProperties: false,
2326
2422
  },
2327
2423
  },
2424
+ {
2425
+ name: "sd_invitation_followups",
2426
+ path: "/sd/invitation_followups",
2427
+ description: "List this user's invitation follow-ups: exact messages that are sent once a LinkedIn invitation is proven accepted. States: pending (waiting for acceptance, or accepted and waiting for a send slot), sending, sent, cancelled, expired (not accepted within 30 days), needs_review (the thread moved, e.g. they wrote first, or the outcome is unknown; never retried automatically).",
2428
+ inputSchema: {
2429
+ type: "object",
2430
+ properties: {
2431
+ state: { type: "string", enum: ["pending", "sending", "sent", "cancelled", "expired", "needs_review"] },
2432
+ },
2433
+ additionalProperties: false,
2434
+ },
2435
+ },
2436
+ {
2437
+ name: "sd_invitation_followup_create",
2438
+ path: "/sd/invitation_followup_create",
2439
+ description: "Register an after-acceptance follow-up message for an invitation that was ALREADY sent. First call without confirm: SignalDash proves a pending sent invitation to this exact provider_id exists and shows the exact text. After the human approves, repeat the same provider_id, text and invitation_id with confirm:true.",
2440
+ inputSchema: {
2441
+ type: "object",
2442
+ properties: {
2443
+ provider_id: { type: "string", minLength: 1, maxLength: 500 },
2444
+ text: { type: "string", minLength: 1, maxLength: 5000 },
2445
+ invitation_id: { type: "string", minLength: 1, maxLength: 500 },
2446
+ confirm: { type: "boolean", default: false },
2447
+ },
2448
+ required: ["provider_id", "text"],
2449
+ additionalProperties: false,
2450
+ },
2451
+ },
2452
+ {
2453
+ name: "sd_invitation_followup_cancel",
2454
+ path: "/sd/invitation_followup_cancel",
2455
+ description: "Cancel one exact invitation follow-up while it is still pending. Requires id and confirm:true. It cannot stop or unsend one already being sent or sent.",
2456
+ inputSchema: {
2457
+ type: "object",
2458
+ properties: {
2459
+ id: { type: "string", format: "uuid" },
2460
+ confirm: { type: "boolean", const: true },
2461
+ },
2462
+ required: ["id", "confirm"],
2463
+ additionalProperties: false,
2464
+ },
2465
+ },
2328
2466
  {
2329
2467
  name: "sd_followup_create",
2330
2468
  path: "/sd/followup_create",
@@ -2587,7 +2725,7 @@ export async function cmdStatus(dependencies = {}) {
2587
2725
  const request = dependencies.request || api;
2588
2726
  log("");
2589
2727
  log(" " + chalk.bold("SignalDash") + chalk.dim(" " + (cfg.backend || DEFAULT_BACKEND)));
2590
- for (const provider of ["linkedin", "whatsapp", "email"]) {
2728
+ for (const provider of ["linkedin", "whatsapp", "instagram", "email"]) {
2591
2729
  const r = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
2592
2730
  const ok = r.status === 200 && r.json.connected;
2593
2731
  const unavailable = r.json?.code === "upstream_authentication_failed"
@@ -2726,7 +2864,7 @@ function printHelp(log = console.log) {
2726
2864
  signaldash <invite-code> set up everything in one go
2727
2865
  signaldash login-token <token> store a session token you were given
2728
2866
  signaldash status show what is connected
2729
- signaldash connect linkedin|whatsapp|email
2867
+ signaldash connect linkedin|whatsapp|instagram|email
2730
2868
  signaldash connections [file.csv] export your LinkedIn connections
2731
2869
  signaldash skill install the agent skill
2732
2870
  signaldash mcp run the MCP server (used by your agent)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "test": "node --test",
22
- "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/outbound-text-policy.cjs && node --check server/intro-request-notifier.cjs && node --check server/content-pipeline.cjs && node --check server/x-signal-engine.cjs && node --check server/buffer-calendar.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/media-fetch.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-push.cjs && node --check server/secretary-rules.cjs && node --check server/secretary-shadow-gate.cjs && node --check server/secretary-vault-importer.cjs && node --check server/secretary-inspiration.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/draft-rewrite-worker.cjs && node --check server/message-scheduler.cjs && node --check server/wa-chat-index.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 scripts/extract-conversation-inspiration.mjs && node --check scripts/render-content-board.mjs && node --check scripts/collect-x-signals.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 && node --check lib/x-signal-collector.cjs"
22
+ "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/outbound-text-policy.cjs && node --check server/intro-request-notifier.cjs && node --check server/content-pipeline.cjs && node --check server/x-signal-engine.cjs && node --check server/buffer-calendar.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/media-fetch.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-push.cjs && node --check server/secretary-rules.cjs && node --check server/secretary-shadow-gate.cjs && node --check server/secretary-vault-importer.cjs && node --check server/secretary-inspiration.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/draft-rewrite-worker.cjs && node --check server/message-scheduler.cjs && node --check server/invitation-followups.cjs && node --check server/wa-chat-index.cjs && node --check server/instagram-budget.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 scripts/extract-conversation-inspiration.mjs && node --check scripts/render-content-board.mjs && node --check scripts/collect-x-signals.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 && node --check lib/x-signal-collector.cjs"
23
23
  },
24
24
  "keywords": [
25
25
  "mcp",
@@ -261,9 +261,12 @@ Run a missing provider explicitly:
261
261
  ```bash
262
262
  npx -y @floomhq/signaldash connect linkedin
263
263
  npx -y @floomhq/signaldash connect whatsapp
264
+ npx -y @floomhq/signaldash connect instagram
264
265
  npx -y @floomhq/signaldash connect email
265
266
  ```
266
267
 
268
+ Instagram is opt-in like email: the one-command setup does not connect it.
269
+
267
270
  Interpret output as follows:
268
271
 
269
272
  - Success: `Connected linkedin: <name>` or `status` shows `+ linkedin`.
@@ -417,7 +420,7 @@ Use the exact tool names and argument keys below. Limits are optional.
417
420
  | `li_mark_read` | `chat_id` required | Explicitly clear one LinkedIn chat's unread state. Reading never clears unread automatically. |
418
421
  | `li_start_chat` | `member_ids` required array of 1-10 exact `ACo`, `ACw`, or `AE` provider member IDs; `text` required exact first message; `confirm`, `approval_hash`, and `dry_run:true` apply only after preview | Start one direct or group chat. Preview first, show the complete member-ID set and exact text, then repeat that exact payload with the returned approval hash only after explicit approval. SignalDash resolves every member live, blocks protected or suppressed contacts, returns an existing exact member-set chat without sending, and charges one thread-message plus aggregate action for a new chat regardless of member count. `dry_run:true` proves the full path up to the provider write without consuming approval or budget. |
419
422
  | `li_send_message` | Manual path: `chat_id` and `text` required; `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean, default true. 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. |
420
- | `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. |
423
+ | `li_send_invitation` | `provider_id` required; `note` optional, max 300 exact characters; `follow_up` optional, max 5000; `note_mode` optional, `follow_up` (default) or `inline`; `confirm` optional, default false | First preview one exact target, note and follow-up. By default a note is NOT put on the invitation (free accounts get notes refused with provider_http_400/422): the invitation goes out bare and the note text becomes a pending invitation follow-up, sent once as the first message after the person is proven to have accepted. `note_mode:"inline"` keeps the note on the invitation. 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. |
421
424
  | `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. |
422
425
  | `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. |
423
426
  | `li_invitations_sent` | `limit` integer 1-100, default 50; `cursor` optional | List one bounded page of sent invitations. This read authorizes only the exact returned invitation IDs for a later withdrawal. |
@@ -449,6 +452,11 @@ Use the exact tool names and argument keys below. Limits are optional.
449
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. |
450
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. |
451
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. |
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
+ | `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
+ | `ig_mark_read` | `chat_id` required | Explicitly clear one Instagram chat's unread state. |
458
+ | `ig_get_attachment` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each | Download one attachment of one Instagram DM in a chat this account owns, stored on the SignalDash host. |
459
+ | `ig_send_message` | `chat_id` and `text` required, text max 5000 characters; `expected_watermark` optional; `mark_read` optional boolean, default true; `confirm_resend:true` only after a re-read following `409 send_outcome_unknown` | Send one approved Instagram DM, text only, in an existing chat after an immediate re-read. Same guards as `wa_send_message`: `428 read_before_send_required`, `409 thread_changed`, `409 duplicate_send`, `409 send_outcome_unknown`, `409 contact_suppressed`. Before writing it confirms the account bound to this user is a ready Instagram account (`409 instagram_account_unverified` otherwise). A thread whose read page holds no message of this account counts as a NEW conversation, which has a much smaller cap. Budget refusals are `429` with an exact code: `instagram_pacing_cooldown`, `instagram_hourly_limit`, `instagram_daily_limit`, `instagram_weekly_limit`, `instagram_new_chat_daily_limit`, `instagram_new_chat_weekly_limit`, `instagram_account_paused`; each carries `retry_after_seconds` and the full `instagram_budget`. Never retry early or work around one. |
452
460
  | `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. SignalDash re-reads after the delete and returns success only when `deleted:1` or a genuine 404 proves the message gone. A present row or failed readback returns `502 deleted_unconfirmed`, records an unknown outcome, and must not be retried. |
453
461
  | `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. |
454
462
  | `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. |
@@ -478,6 +486,9 @@ Use the exact tool names and argument keys below. Limits are optional.
478
486
  | `sd_schedule_message` | `channel` required, `whatsapp` or `linkedin`; `chat_id` required, max 500; `text` required, max 5000; `scheduled_at` required offset-qualified ISO date-time from 60 seconds to 365 days ahead; `confirm:true` required | Schedule one exact message into one chat you have already read. Read that exact chat first, at `limit` 10 or more on LinkedIn: SignalDash records what the thread looked like and refuses at send time if the conversation moved. Text only; attachments are refused rather than dropped. One message at one time, never a sequence. |
479
487
  | `sd_scheduled_messages` | `state` optional, one of `scheduled`, `executing`, `sent`, `cancelled`, `failed`, `needs_review`; `channel` optional | List only this authenticated user's scheduled messages and their durable states. Returns every matching row, unpaginated, and always reports `needs_review_count` outside your filter. |
480
488
  | `sd_cancel_scheduled_message` | `id` required UUID; `confirm:true` required | Cancel one exact message while its state is still `scheduled`. It cannot stop one already being sent, and it cannot unsend one that has been sent. |
489
+ | `sd_invitation_followups` | `state` optional: `pending`, `sending`, `sent`, `cancelled`, `expired`, `needs_review` | List invitation follow-ups and their durable states, with `needs_review_count`. Acceptance is proven hourly per pending row (invitation gone from a fully read sent list AND first-degree, or a chat exists); the send goes through the paced `li_send_message` write path once. If they already wrote, or the thread holds an outbound message, nothing is sent and the row becomes `needs_review`. Not accepted within 30 days: `expired`. |
490
+ | `sd_invitation_followup_create` | `provider_id`, `text` required; `invitation_id` optional; `confirm` optional | Register a follow-up for an invitation that was already sent. Preview proves a pending sent invitation to this exact person; after approval repeat the identical call with `confirm:true`. One pending follow-up per person. |
491
+ | `sd_invitation_followup_cancel` | `id` required UUID; `confirm:true` required | Cancel one exact follow-up while it is still `pending`. It cannot stop or unsend one being sent or sent. |
481
492
  | `sd_followup_create` | `channel`, exact `thread_id`, task `context`, and offset-qualified `due_at` required; optional completion condition, auto-close choice, recipient label, origin, source message, and baseline | Record one independent task inside a LinkedIn, WhatsApp, email, or Slack conversation. Multiple different tasks may remain active in the same thread. Monitoring only: this never sends. |
482
493
  | `sd_followups` | optional `status`, `channel`, and `overdue` filters | Reconcile and list task-level expectations. A reply either completes an explicitly auto-close task or moves it to `needs_review`; it never clears unrelated tasks in the same thread. |
483
494
  | `sd_followup_cancel` | `id` required UUID; `confirm:true` required | Cancel one exact active task without deleting communication history or sending anything. |
@@ -705,6 +716,29 @@ time and work window after a campaign action has established them. The
705
716
  scheduler, persisted tenant-breaker, active-batch, and target-count fields
706
717
  reflect the current invitation-batch runtime.
707
718
 
719
+ ### Instagram DM budget
720
+
721
+ Instagram has its own budget per connected Instagram account. It never spends
722
+ LinkedIn lanes or the WhatsApp/email send lane, and `sd_budget_status` reports
723
+ it under `instagram`, so "my action budget" always includes Instagram once it
724
+ is connected. Defaults:
725
+
726
+ - 50 sends per UTC day, of which at most 15 may open a new conversation;
727
+ - 250 sends and 60 new conversations per UTC week, reset Monday 00:00 UTC;
728
+ - 10 sends per rolling hour;
729
+ - at least 45 seconds between sends, plus 0 to 30 seconds of random jitter;
730
+ - warm-up after connecting: first 24 hours 10 replies and no new conversation,
731
+ days 1 to 6 20 sends and 5 new conversations, days 7 to 13 35 sends and 10
732
+ new conversations, full limits from day 14;
733
+ - a provider 403 or 429 pauses Instagram sends for 24 hours.
734
+
735
+ A new conversation is a send into a thread whose read page holds no message
736
+ from this account (a cold DM or a first answer to a message request). Replies
737
+ are everything else. Prefer replies; never start cold Instagram outreach
738
+ without the human approving each exact message. An operator can set a
739
+ per-account override, but the hard ceilings stay at 100 per day, 10 per hour
740
+ and 40 new conversations per day.
741
+
708
742
  ### Opt-in invitation auto-accept
709
743
 
710
744
  `sd_settings_get` is the single read surface for persistent SignalDash
@@ -1602,6 +1636,24 @@ looping `wa_delete_message` yourself: it paces them and never runs two at once.
1602
1636
  Read the per-entry results, and resend only the entries marked
1603
1637
  `code:batch_deadline`, which were never attempted.
1604
1638
 
1639
+ ### Flow 3b: find and reply to an Instagram DM
1640
+
1641
+ Instagram is connected with `signaldash connect instagram`, which prints a
1642
+ link the human opens in their own browser to sign in to Instagram. The account
1643
+ is bound to that human's own SignalDash user, never to the workspace.
1644
+
1645
+ 1. `sd_budget_status` and read `instagram`: remaining sends, remaining new
1646
+ conversations, warm-up stage, and `next_send_allowed_at`.
1647
+ 2. `ig_list_chats` with `search` or `unread:true` to find the exact `chat_id`.
1648
+ 3. `ig_read_messages` for that chat. Draft the reply and show the human the
1649
+ exact text.
1650
+ 4. After approval, `ig_send_message` with `chat_id`, `text` and the
1651
+ `expected_watermark` from the read. On `409 thread_changed`, show the new
1652
+ messages and redraft. On any `429 instagram_*` code, stop and report the
1653
+ cap and `retry_after_seconds`; never retry early.
1654
+ 5. `ig_mark_read` only when the human wants the chat cleared and the send did
1655
+ not already clear it.
1656
+
1605
1657
  ### Flow 4: send, accept, or withdraw one LinkedIn invitation
1606
1658
 
1607
1659
  For a new invitation: