@floomhq/signaldash 0.41.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.41.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)`
@@ -728,6 +734,33 @@ workflow around that runtime control:
728
734
  the conservative reading is the safe one) and clears at the next UTC midnight;
729
735
  LinkedIn, WhatsApp/email, and delete ledgers are keyed to the stable logical
730
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).
731
764
  - Read the exact thread before every send.
732
765
  - Preview an exact invitation target and note before sending it.
733
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
  }
@@ -941,7 +941,7 @@ const TOOLS = [
941
941
  properties: {
942
942
  channel: {
943
943
  type: "string",
944
- enum: ["linkedin", "whatsapp", "email"],
944
+ enum: ["linkedin", "whatsapp", "instagram", "email"],
945
945
  },
946
946
  identifiers: {
947
947
  type: "array",
@@ -992,7 +992,7 @@ const TOOLS = [
992
992
  {
993
993
  name: "sd_budget_status",
994
994
  path: "/sd/budget_status",
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.",
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.",
996
996
  inputSchema: {
997
997
  type: "object",
998
998
  properties: {},
@@ -1679,6 +1679,85 @@ const TOOLS = [
1679
1679
  additionalProperties: false,
1680
1680
  },
1681
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
+ },
1682
1761
  {
1683
1762
  name: "email_list",
1684
1763
  path: "/email/list",
@@ -2646,7 +2725,7 @@ export async function cmdStatus(dependencies = {}) {
2646
2725
  const request = dependencies.request || api;
2647
2726
  log("");
2648
2727
  log(" " + chalk.bold("SignalDash") + chalk.dim(" " + (cfg.backend || DEFAULT_BACKEND)));
2649
- for (const provider of ["linkedin", "whatsapp", "email"]) {
2728
+ for (const provider of ["linkedin", "whatsapp", "instagram", "email"]) {
2650
2729
  const r = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
2651
2730
  const ok = r.status === 200 && r.json.connected;
2652
2731
  const unavailable = r.json?.code === "upstream_authentication_failed"
@@ -2785,7 +2864,7 @@ function printHelp(log = console.log) {
2785
2864
  signaldash <invite-code> set up everything in one go
2786
2865
  signaldash login-token <token> store a session token you were given
2787
2866
  signaldash status show what is connected
2788
- signaldash connect linkedin|whatsapp|email
2867
+ signaldash connect linkedin|whatsapp|instagram|email
2789
2868
  signaldash connections [file.csv] export your LinkedIn connections
2790
2869
  signaldash skill install the agent skill
2791
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.41.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/invitation-followups.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`.
@@ -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. |
@@ -708,6 +716,29 @@ time and work window after a campaign action has established them. The
708
716
  scheduler, persisted tenant-breaker, active-batch, and target-count fields
709
717
  reflect the current invitation-batch runtime.
710
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
+
711
742
  ### Opt-in invitation auto-accept
712
743
 
713
744
  `sd_settings_get` is the single read surface for persistent SignalDash
@@ -1605,6 +1636,24 @@ looping `wa_delete_message` yourself: it paces them and never runs two at once.
1605
1636
  Read the per-entry results, and resend only the entries marked
1606
1637
  `code:batch_deadline`, which were never attempted.
1607
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
+
1608
1657
  ### Flow 4: send, accept, or withdraw one LinkedIn invitation
1609
1658
 
1610
1659
  For a new invitation: