@floomhq/signaldash 0.39.6 → 0.39.9

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
@@ -120,7 +120,7 @@ setup installs both from the same pinned npm package
120
120
  the human chose to execute:
121
121
 
122
122
  ```bash
123
- npx -y @floomhq/signaldash@0.39.6 <invite-code>
123
+ npx -y @floomhq/signaldash@0.39.9 <invite-code>
124
124
  ```
125
125
 
126
126
  Run that command in a terminal, not in an agent chat. Do not ask an agent to
@@ -168,7 +168,7 @@ SignalDash exposes:
168
168
  - `li_create_invitation_batch(source_label, time_zone, targets)`
169
169
  - `li_get_invitation_batch(batch_id)`
170
170
  - `li_cancel_invitation_batch(batch_id, approval_view_hash, confirm)`
171
- - `sd_campaign_create(source_label, time_zone, messages, target_source?, targets?, engagers?, invite_ttl_days?)`
171
+ - `sd_campaign_create(source_label, time_zone, messages?, target_source?, targets?, engagers?, invite_ttl_days?)`
172
172
  - `sd_campaign_preview(campaign_id)`
173
173
  - `sd_campaign_approve(campaign_id, confirm_token)`
174
174
  - `sd_campaign_status(campaign_id)`
@@ -192,12 +192,17 @@ SignalDash exposes:
192
192
  - `li_my_posts(limit, member_id)`
193
193
  - `li_post_reactions(post_id, limit, cursor?)`
194
194
  - `li_post_comments(post_id, comment_id?, resolve_reply_state?, limit, cursor?)`
195
- - `li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, expected_watermark?, secretary_receipt_id?)`
195
+ - `li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, mentions?, expected_watermark?, secretary_receipt_id?)`
196
196
  - `li_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)`
197
197
  - `li_delete_message(chat_id, message_id, confirm)`
198
198
  - `li_delete_comment(post_id, comment_id, confirm)`
199
199
  - `li_draft_post(text, publish, scheduled_at?, content_pipeline_id?, content_pipeline_override?, mentions?, attachments?, first_comment?)`
200
200
  - `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
201
+ - `li_save_post_draft(text, mentions?, attachments?, first_comment?, source_key?, metadata?)`
202
+ - `li_post_drafts()`
203
+ - `li_update_post_draft(id, text, expected_version, mentions?, attachments?, first_comment?)`
204
+ - `li_delete_post_draft(id, expected_version, confirm)`
205
+ - `li_post_draft_preview(id)`
201
206
  - `li_scheduled_posts()`
202
207
  - `li_scheduled_post_preview(id)`
203
208
  - `li_edit_scheduled_post(id, text, scheduled_at, mentions?, attachments?, first_comment?, confirm?, approval_hash?, expected_version?, expected_updated_at?)`
@@ -379,6 +384,35 @@ uses the same sender binding, action ledger, duplicate guard, daily budget, and
379
384
  provider-warning lock as immediate publishing. An interrupted or ambiguous
380
385
  execution fails closed and is never retried automatically.
381
386
 
387
+ Arbitrary saved LinkedIn drafts use a separate durable store and never enter
388
+ the scheduling or publishing worker. `li_save_post_draft` stores exact text,
389
+ up to 25 mentions, up to 20 ordered images, and an optional first comment.
390
+ Each image is limited to 5 MiB and one draft to 12 MiB; each tenant is limited
391
+ to 1,000 drafts and 100 MiB of decoded attachment bytes. Ordinary duplicate
392
+ drafts remain distinct records. Imports may instead supply a tenant-scoped
393
+ `source_key`: an identical replay returns the existing draft, while a replay
394
+ with different content is refused. Bounded structured `metadata` preserves the
395
+ source, title, storyline, readiness, hold gates, editorial notes, visual notes,
396
+ and revision without placing those fields in the publishable post text. List
397
+ responses expose image metadata rather than base64.
398
+ `li_update_post_draft` is a complete compare-and-swap replacement: pass the
399
+ current `expected_version`, and omission clears optional mentions, images, or
400
+ first comment. `li_delete_post_draft` requires the current version and
401
+ `confirm:true`. None of these operations calls LinkedIn, Unipile, Buffer, or an
402
+ action-budget lane.
403
+
404
+ `li_post_draft_preview` returns a short-lived, one-use
405
+ `https://signaldash.dev/d/<token>` link for one exact saved draft. POSTing its
406
+ confirmation creates a 15-minute cookie restricted to `/drafts`; the page and
407
+ its `/drafts/:id/attachments/:index` media route remain bound to that tenant,
408
+ draft, and originating live SignalDash session. Media is served byte-for-byte
409
+ with `private, no-store`, MIME allowlisting, `nosniff`, and a restrictive CSP.
410
+ The shared calendar displays unscheduled SignalDash and Buffer drafts with the
411
+ same cards as scheduled posts, marked by a visible `DRAFT` tag and an
412
+ `Unscheduled` time label. Saved SignalDash media uses separate protected
413
+ `/calendar/drafts/...` URLs. Drafts never enter the `items` returned by
414
+ `li_scheduled_posts`, preserving its existing calendar contract.
415
+
382
416
  `li_scheduled_posts` is the shared planning read across posts stored by
383
417
  SignalDash and the configured Buffer LinkedIn channel. Every item names its
384
418
  source, and the response reports independent completeness for SignalDash,
@@ -587,9 +621,9 @@ messaging. Campaign invitations require a valid sender IANA timezone, run only
587
621
  Monday through Friday from 09:00 to 17:00 sender-local time, and reserve a new
588
622
  90–180 second per-sender pacing interval atomically with every attempted
589
623
  action. Timing is checked before final provider preflight and again during the
590
- write reservation. Downtime never creates a catch-up burst. Future start
591
- dates, recurring schedules, automatic follow-ups, acceptance-triggered
592
- messages, and multi-message sequences are not exposed.
624
+ write reservation. Downtime never creates a catch-up burst. A campaign can be
625
+ connect-only or carry an exact human-approved acceptance-triggered sequence.
626
+ User-selected future start dates and recurring schedules are not exposed.
593
627
 
594
628
  Deleting a WhatsApp message is a write, and an irreversible one, so it is
595
629
  treated as such. SignalDash proves the chat belongs to the connected account,
package/bin/sd.mjs CHANGED
@@ -133,6 +133,24 @@ export async function api(
133
133
  ) {
134
134
  json.approval_url = new URL(json.approval_path, targetBackend).toString();
135
135
  }
136
+ // The batch magic link gets the same rebase for the same reason: the backend
137
+ // stamps its own configured public origin, but the human has to open the
138
+ // host THIS client is actually talking to. Rebasing only the path keeps the
139
+ // token intact and never logs it; leaving it alone would hand a self-hosted
140
+ // user a link pointing at somebody else's deployment.
141
+ if (
142
+ json &&
143
+ typeof json === "object" &&
144
+ typeof json.approval_link === "string" &&
145
+ json.approval_link
146
+ ) {
147
+ try {
148
+ json.approval_link = new URL(
149
+ new URL(json.approval_link).pathname,
150
+ targetBackend,
151
+ ).toString();
152
+ } catch {}
153
+ }
136
154
  return { status: r.status, json };
137
155
  }
138
156
 
@@ -980,7 +998,7 @@ const TOOLS = [
980
998
  {
981
999
  name: "sd_campaign_create",
982
1000
  path: "/sd/campaign/create",
983
- description: "Create one campaign: a paced connection request to each exact person, then the exact approved message(s) once that person is PROVEN to have accepted, then an optional follow-up that stops the moment they reply. Nothing is sent until a human approves this exact recipient list and this exact message text on the approval page. Targets come from an explicit list you supply (for example one you built with li_search_connections or li_post_reactions) or from your own post engagers, which costs zero profile fetches. If the user already sent someone a connection request by hand and just wants the follow-up automated, set adopt_existing_invitation on that target instead of leaving them out. Draft the messages in the user's own voice and keep them short: the on-acceptance group is 2-3 separate short sends, never one block.",
1001
+ description: "Create one campaign: a paced connection request to each exact person, optionally followed by exact approved message(s) once that person is PROVEN to have accepted. Omit messages or pass an empty array for a connect-only campaign; no message ever follows. Nothing is sent until a human approves the exact recipient list and actions on the approval page. Targets come from an explicit list you supply (for example one you built with li_search_connections or li_post_reactions) or from your own post engagers, which costs zero profile fetches. If the user already sent someone a connection request by hand and wants an approved follow-up automated, set adopt_existing_invitation on that target instead of leaving them out. When messages are present, draft them in the user's own voice and keep them short: the on-acceptance group is 2-3 separate short sends, never one block.",
984
1002
  inputSchema: {
985
1003
  type: "object",
986
1004
  properties: {
@@ -1036,9 +1054,9 @@ const TOOLS = [
1036
1054
  },
1037
1055
  messages: {
1038
1056
  type: "array",
1039
- minItems: 1,
1057
+ minItems: 0,
1040
1058
  maxItems: 5,
1041
- description: "The exact frozen texts. after_days 0 means sent once the invitation is accepted (1-3 of these, sent as separate consecutive messages); a later step needs after_days 1-30 and only goes out if there has been no reply.",
1059
+ description: "Optional exact frozen texts. Empty means connect-only and no message ever follows. after_days 0 means sent once the invitation is accepted (1-3 of these, sent as separate consecutive messages); a later step needs after_days 1-30 and only goes out if there has been no reply.",
1042
1060
  items: {
1043
1061
  type: "object",
1044
1062
  properties: {
@@ -1057,7 +1075,7 @@ const TOOLS = [
1057
1075
  description: "An invitation not accepted within this many days is dropped and never messaged.",
1058
1076
  },
1059
1077
  },
1060
- required: ["source_label", "time_zone", "messages"],
1078
+ required: ["source_label", "time_zone"],
1061
1079
  additionalProperties: false,
1062
1080
  },
1063
1081
  },
@@ -1546,7 +1564,7 @@ const TOOLS = [
1546
1564
  {
1547
1565
  name: "li_reply_to_comment",
1548
1566
  path: "/li/reply_to_comment",
1549
- description: "Reply once to one exact inbound comment on the authenticated sender's own LinkedIn post. First read li_post_comments and preserve the exact post_id, parent_comment_id, trigger_comment_id, text, and returned signaldash_watermark. SignalDash re-proves post ownership, trigger identity and content, absence of an own duplicate, sender generation, budget, and provider safety immediately before writing. A 2xx is not success until bounded readback finds exactly one own reply. A Secretary approval uses secretary_receipt_id alone.",
1567
+ 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.",
1550
1568
  inputSchema: {
1551
1569
  type: "object",
1552
1570
  properties: {
@@ -1554,6 +1572,18 @@ const TOOLS = [
1554
1572
  parent_comment_id: { type: "string", minLength: 1, maxLength: 500 },
1555
1573
  trigger_comment_id: { type: "string", minLength: 1, maxLength: 500 },
1556
1574
  text: { type: "string", minLength: 1, maxLength: 1250 },
1575
+ mentions: {
1576
+ type: "array", maxItems: 20,
1577
+ 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.",
1578
+ items: {
1579
+ type: "object",
1580
+ properties: {
1581
+ name: { type: "string", minLength: 1, maxLength: 120 },
1582
+ profile_id: { type: "string", minLength: 1, maxLength: 250 },
1583
+ },
1584
+ required: ["name", "profile_id"],
1585
+ },
1586
+ },
1557
1587
  expected_watermark: { type: "string", pattern: "^[0-9a-f]{64}$" },
1558
1588
  secretary_receipt_id: { type: "string", minLength: 1, maxLength: 500 },
1559
1589
  },
@@ -1609,7 +1639,7 @@ const TOOLS = [
1609
1639
  {
1610
1640
  name: "li_draft_post",
1611
1641
  path: "/li/create_post",
1612
- description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. 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 single-use approval_hash. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
1642
+ description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. 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 single-use approval_hash. Optional mentions, up to 20 base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
1613
1643
  inputSchema: {
1614
1644
  type: "object",
1615
1645
  properties: {
@@ -1629,7 +1659,7 @@ const TOOLS = [
1629
1659
  },
1630
1660
  first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1631
1661
  mentions: {
1632
- type: "array", maxItems: 20,
1662
+ type: "array", maxItems: 25,
1633
1663
  items: {
1634
1664
  type: "object",
1635
1665
  properties: {
@@ -1640,7 +1670,7 @@ const TOOLS = [
1640
1670
  },
1641
1671
  },
1642
1672
  attachments: {
1643
- type: "array", maxItems: 4,
1673
+ type: "array", maxItems: 20,
1644
1674
  items: {
1645
1675
  type: "object",
1646
1676
  properties: {
@@ -1671,12 +1701,238 @@ const TOOLS = [
1671
1701
  additionalProperties: false,
1672
1702
  },
1673
1703
  },
1704
+ {
1705
+ name: "li_save_post_draft",
1706
+ path: "/li/save_post_draft",
1707
+ description: "Persist one arbitrary LinkedIn post draft without scheduling or publishing it. Stores exact text, ordered image bytes, mentions, and an optional first comment. Duplicate drafts are allowed. No provider call or action budget is used.",
1708
+ inputSchema: {
1709
+ type: "object",
1710
+ properties: {
1711
+ text: { type: "string", minLength: 1, maxLength: 3000 },
1712
+ mentions: {
1713
+ type: "array", maxItems: 25,
1714
+ items: {
1715
+ type: "object",
1716
+ properties: {
1717
+ name: { type: "string", minLength: 1, maxLength: 120 },
1718
+ profile_id: { type: "string", minLength: 1, maxLength: 250 },
1719
+ },
1720
+ required: ["name", "profile_id"],
1721
+ additionalProperties: false,
1722
+ },
1723
+ },
1724
+ attachments: {
1725
+ type: "array", maxItems: 20,
1726
+ items: {
1727
+ type: "object",
1728
+ properties: {
1729
+ filename: { type: "string", minLength: 1, maxLength: 180 },
1730
+ content_type: { type: "string", enum: ["image/png", "image/jpeg", "image/webp", "image/gif"] },
1731
+ content_base64: { type: "string", minLength: 1 },
1732
+ },
1733
+ required: ["filename", "content_type", "content_base64"],
1734
+ additionalProperties: false,
1735
+ },
1736
+ },
1737
+ first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1738
+ source_key: {
1739
+ type: "string",
1740
+ minLength: 1,
1741
+ maxLength: 256,
1742
+ description: "Optional tenant-scoped idempotency key for a durable source item. Reusing it with different content is refused.",
1743
+ },
1744
+ metadata: {
1745
+ type: "object",
1746
+ properties: {
1747
+ source_group: { type: "string", minLength: 1, maxLength: 100 },
1748
+ source_batch_id: { type: "string", minLength: 1, maxLength: 200 },
1749
+ source_item_id: { type: "string", minLength: 1, maxLength: 200 },
1750
+ title: { type: "string", minLength: 1, maxLength: 500 },
1751
+ storyline: { type: "string", minLength: 1, maxLength: 2000 },
1752
+ readiness: { type: "string", enum: ["ready", "held"] },
1753
+ hold_gates: {
1754
+ type: "array",
1755
+ maxItems: 20,
1756
+ items: {
1757
+ type: "object",
1758
+ properties: {
1759
+ code: { type: "string", minLength: 1, maxLength: 100 },
1760
+ note: { type: "string", minLength: 1, maxLength: 2000 },
1761
+ },
1762
+ required: ["code", "note"],
1763
+ additionalProperties: false,
1764
+ },
1765
+ },
1766
+ source: {
1767
+ type: "object",
1768
+ properties: {
1769
+ label: { type: "string", minLength: 1, maxLength: 500 },
1770
+ id: { type: "string", minLength: 1, maxLength: 500 },
1771
+ text: { type: "string", minLength: 1, maxLength: 12000 },
1772
+ },
1773
+ additionalProperties: false,
1774
+ },
1775
+ status: { type: "string", minLength: 1, maxLength: 100 },
1776
+ editorial_notes: {
1777
+ type: "array",
1778
+ maxItems: 20,
1779
+ items: { type: "string", minLength: 1, maxLength: 4000 },
1780
+ },
1781
+ visual: { type: "string", minLength: 1, maxLength: 4000 },
1782
+ revision: { type: "integer", minimum: 0 },
1783
+ },
1784
+ additionalProperties: false,
1785
+ },
1786
+ },
1787
+ required: ["text"],
1788
+ additionalProperties: false,
1789
+ },
1790
+ },
1791
+ {
1792
+ name: "li_post_drafts",
1793
+ path: "/li/post_drafts",
1794
+ description: "List this account's durable SignalDash LinkedIn post drafts and return a fresh one-use calendar link. Attachment metadata is returned, never base64 bytes. These drafts remain separate from scheduled posts and do not appear in li_scheduled_posts items.",
1795
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1796
+ },
1797
+ {
1798
+ name: "li_update_post_draft",
1799
+ path: "/li/update_post_draft",
1800
+ description: "Atomically replace one complete saved LinkedIn draft using its exact UUID and expected_version. Omitted mentions, attachments, or first_comment are cleared. A stale version is refused. No provider call or action budget is used.",
1801
+ inputSchema: {
1802
+ type: "object",
1803
+ properties: {
1804
+ id: { type: "string", format: "uuid" },
1805
+ text: { type: "string", minLength: 1, maxLength: 3000 },
1806
+ mentions: {
1807
+ type: "array", maxItems: 25,
1808
+ items: {
1809
+ type: "object",
1810
+ properties: {
1811
+ name: { type: "string", minLength: 1, maxLength: 120 },
1812
+ profile_id: { type: "string", minLength: 1, maxLength: 250 },
1813
+ },
1814
+ required: ["name", "profile_id"],
1815
+ additionalProperties: false,
1816
+ },
1817
+ },
1818
+ attachments: {
1819
+ type: "array", maxItems: 20,
1820
+ items: {
1821
+ type: "object",
1822
+ properties: {
1823
+ filename: { type: "string", minLength: 1, maxLength: 180 },
1824
+ content_type: { type: "string", enum: ["image/png", "image/jpeg", "image/webp", "image/gif"] },
1825
+ content_base64: { type: "string", minLength: 1 },
1826
+ },
1827
+ required: ["filename", "content_type", "content_base64"],
1828
+ additionalProperties: false,
1829
+ },
1830
+ },
1831
+ first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1832
+ expected_version: { type: "integer", minimum: 0 },
1833
+ },
1834
+ required: ["id", "text", "expected_version"],
1835
+ additionalProperties: false,
1836
+ },
1837
+ },
1838
+ {
1839
+ name: "li_delete_post_draft",
1840
+ path: "/li/delete_post_draft",
1841
+ description: "Permanently delete one exact saved LinkedIn draft. Requires its UUID, current expected_version, and confirm:true. A stale version is refused. This does not delete a LinkedIn-native or scheduled post.",
1842
+ inputSchema: {
1843
+ type: "object",
1844
+ properties: {
1845
+ id: { type: "string", format: "uuid" },
1846
+ expected_version: { type: "integer", minimum: 0 },
1847
+ confirm: { type: "boolean", const: true },
1848
+ },
1849
+ required: ["id", "expected_version", "confirm"],
1850
+ additionalProperties: false,
1851
+ },
1852
+ },
1853
+ {
1854
+ name: "li_post_draft_preview",
1855
+ path: "/li/post_draft_preview",
1856
+ description: "Create a short-lived one-use browser link for one exact saved SignalDash LinkedIn draft. The isolated read-only page renders only that draft, its protected stored images, and its draft first comment.",
1857
+ inputSchema: {
1858
+ type: "object",
1859
+ properties: {
1860
+ id: { type: "string", format: "uuid" },
1861
+ },
1862
+ required: ["id"],
1863
+ additionalProperties: false,
1864
+ },
1865
+ },
1674
1866
  {
1675
1867
  name: "li_scheduled_posts",
1676
1868
  path: "/li/scheduled_posts",
1677
1869
  description: "Read the shared LinkedIn content calendar from SignalDash and the configured Buffer channel. Always inspect completeness and per-source state. Native LinkedIn scheduled posts and drafts are NOT visible because Unipile has no documented read route for them, and SignalDash does not use raw Voyager routes or linkedin.com browser access. An empty items array is never proof that the native LinkedIn calendar is empty.",
1678
1870
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1679
1871
  },
1872
+ {
1873
+ name: "li_profile",
1874
+ path: "/li/profile",
1875
+ description: "Read one LinkedIn profile by public id or member id. Returns name, headline, public identifier, provider id, network distance and the profile picture url. Read-only, one provider call, no write. Exists because pictures were otherwise only available for people who had already reacted to or commented on your own posts, which leaves every new name in a shortlist faceless.",
1876
+ inputSchema: {
1877
+ type: "object",
1878
+ properties: { identifier: { type: "string", minLength: 1, maxLength: 200 } },
1879
+ required: ["identifier"],
1880
+ additionalProperties: false,
1881
+ },
1882
+ },
1883
+ {
1884
+ name: "sd_share_list",
1885
+ path: "/sd/share_list",
1886
+ description: "Share one selection as a standalone signaldash.dev link, or list and revoke the links that exist. Pass `people` instead of `rows` for a shortlist of humans: each entry renders as a card with a photo when the provider gives one and initials when it does not, plus an optional `score` out of 100 and the `reason` that earned it. A score without a reason is a claim without evidence, so send both or neither, and put the scoring rule in `rubric` where the reader can see it. action:create returns the url ONCE and stores only its hash, so the link cannot be recovered later, only revoked. The page is a fixed snapshot: it never re-reads the source, so a list shared today does not keep disclosing tomorrow's data. action:list shows every live link with its open count; action:revoke kills one by its token.",
1887
+ inputSchema: {
1888
+ type: "object",
1889
+ properties: {
1890
+ action: { type: "string", enum: ["create", "list", "revoke"] },
1891
+ title: { type: "string", minLength: 1, maxLength: 200 },
1892
+ note: { type: "string", maxLength: 4000 },
1893
+ columns: { type: "array", minItems: 1, maxItems: 12, items: { type: "string", minLength: 1, maxLength: 80 } },
1894
+ rows: {
1895
+ type: "array", maxItems: 2000,
1896
+ items: { type: "array", items: { type: "string", maxLength: 2000 } },
1897
+ },
1898
+ people: {
1899
+ type: "array", maxItems: 500,
1900
+ items: {
1901
+ type: "object",
1902
+ properties: {
1903
+ name: { type: "string", minLength: 1, maxLength: 200 },
1904
+ headline: { type: "string", maxLength: 500 },
1905
+ reason: { type: "string", maxLength: 600 },
1906
+ url: { type: "string", maxLength: 500 },
1907
+ image: { type: "string", maxLength: 800 },
1908
+ score: { type: "number", minimum: 0, maximum: 100 },
1909
+ meta: { type: "array", maxItems: 4, items: { type: "string", maxLength: 80 } },
1910
+ org: { type: "string", maxLength: 120 },
1911
+ tags: { type: "array", maxItems: 5, items: { type: "string", maxLength: 28 } },
1912
+ factors: {
1913
+ type: "array", maxItems: 6,
1914
+ items: {
1915
+ type: "object",
1916
+ properties: {
1917
+ label: { type: "string", minLength: 1, maxLength: 60 },
1918
+ points: { type: "number" },
1919
+ },
1920
+ required: ["label"],
1921
+ additionalProperties: false,
1922
+ },
1923
+ },
1924
+ },
1925
+ required: ["name"],
1926
+ additionalProperties: false,
1927
+ },
1928
+ },
1929
+ rubric: { type: "string", maxLength: 1200 },
1930
+ token: { type: "string", minLength: 1, maxLength: 200 },
1931
+ list_id: { type: "string", minLength: 1, maxLength: 64 },
1932
+ },
1933
+ additionalProperties: false,
1934
+ },
1935
+ },
1680
1936
  {
1681
1937
  name: "li_scheduled_post_preview",
1682
1938
  path: "/li/scheduled_post_preview",
@@ -1701,7 +1957,7 @@ const TOOLS = [
1701
1957
  text: { type: "string", minLength: 1, maxLength: 3000 },
1702
1958
  scheduled_at: { type: "string", format: "date-time" },
1703
1959
  mentions: {
1704
- type: "array", maxItems: 20,
1960
+ type: "array", maxItems: 25,
1705
1961
  items: {
1706
1962
  type: "object",
1707
1963
  properties: {
@@ -1713,7 +1969,7 @@ const TOOLS = [
1713
1969
  },
1714
1970
  },
1715
1971
  attachments: {
1716
- type: "array", maxItems: 4,
1972
+ type: "array", maxItems: 20,
1717
1973
  items: {
1718
1974
  type: "object",
1719
1975
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.39.6",
3
+ "version": "0.39.9",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -420,7 +420,7 @@ Use the exact tool names and argument keys below. Limits are optional.
420
420
  | `li_create_invitation_batch` | `source_label` required exact string, max 120 code points; `time_zone` required IANA timezone; `targets` required array of 1-10 exact `{profile_url,inclusion_reason,note?}` objects; reason max 240 and note max 200 Unicode code points | Create one immutable durable preview from canonical LinkedIn Classic `/in/` URLs. The agent structures an explicit human request; the server never generates targets or text. |
421
421
  | `li_get_invitation_batch` | `batch_id` required | Inspect every stored target, exact note, exclusion, hash, timing, capacity, and result. Use the returned browser review path for human approval. This read also authorizes a later exact cancel. |
422
422
  | `li_cancel_invitation_batch` | `batch_id` required; `approval_view_hash` required string or null exactly as inspected; `confirm:true` required | Permanently cancel unstarted targets in one freshly inspected batch. It cannot recall an executing invitation, and restarting requires a new preview and approval. |
423
- | `sd_campaign_create` | `source_label` required, max 120 code points; `time_zone` required IANA timezone; `messages` required array of 1-5 exact `{text,after_days?}` objects, text max 1200 code points; `target_source` optional (`explicit` default or `post_engagers`); `targets` array of up to 150 `{profile_url?,provider_id?,inclusion_reason,note?,display_name?,headline?,adopt_existing_invitation?}` for `explicit` (at most 10 adopted targets per campaign, and an adopted target may not carry a `note`); `engagers` object with `post_limit` 1-10, `max_targets` 1-150, `inclusion_reason`, `note` for `post_engagers`; `invite_ttl_days` optional 1-60, default 21 | Create one campaign: a connection request to each exact person, then the exact approved message(s) once that person is proven to have accepted, then an optional follow-up that stops on any reply. The server never generates a target or a word of text. Nothing is sent before human approval. |
423
+ | `sd_campaign_create` | `source_label` required, max 120 code points; `time_zone` required IANA timezone; `messages` optional array of 0-5 exact `{text,after_days?}` objects, text max 1200 code points; omit it or pass `[]` for connect-only; `target_source` optional (`explicit` default or `post_engagers`); `targets` array of up to 150 `{profile_url?,provider_id?,inclusion_reason,note?,display_name?,headline?,adopt_existing_invitation?}` for `explicit` (at most 10 adopted targets per campaign, and an adopted target may not carry a `note`); `engagers` object with `post_limit` 1-10, `max_targets` 1-150, `inclusion_reason`, `note` for `post_engagers`; `invite_ttl_days` optional 1-60, default 21 | Create one campaign: a connection request to each exact person, optionally followed by exact approved messages after proven acceptance. A connect-only campaign completes after its invitations and never watches acceptance or sends a message. The server never generates a target or a word of text. Nothing is sent before human approval. |
424
424
  | `sd_campaign_preview` | `campaign_id` required | Inspect every exact recipient, every exclusion and reason, the exact text of every message step, the timing, the invitation and existing-thread message lanes, the aggregate brake, and the `approval_url` to hand the human. This read also authorizes a later exact cancel. |
425
425
  | `sd_campaign_approve` | `campaign_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. |
426
426
  | `sd_campaign_status` | `campaign_id` required | Monitor per person: invited, acceptance proven, messaged, replied and halted, expired, or excluded, plus every message step and any provider response SignalDash could not parse. |
@@ -444,15 +444,22 @@ Use the exact tool names and argument keys below. Limits are optional.
444
444
  | `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. |
445
445
  | `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. |
446
446
  | `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`. |
447
- | `li_reply_to_comment` | Manual path: `post_id`, `parent_comment_id`, `trigger_comment_id`, and `text` required; `expected_watermark` optional exact 64-character watermark. Secretary path: `secretary_receipt_id` alone. | Reply once to one exact inbound comment on this sender's own post after `li_post_comments`. SignalDash re-proves post ownership, the unchanged trigger, its author and parent, no own duplicate, sender generation, budget, and provider health immediately before writing. A 2xx is not success until readback finds exactly one matching own reply. |
447
+ | `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. |
448
448
  | `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. |
449
449
  | `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. |
450
450
  | `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. |
451
- | `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 20 exact `{name,profile_id}` objects; `attachments` optional array of up to 4 exact `{filename,content_type,content_base64}` images; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. An enabled pipeline normally runs its fact, exact-text, 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 `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. |
451
+ | `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 `{filename,content_type,content_base64}` images, max 5 MiB each and 12 MiB total; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. An enabled pipeline normally runs its fact, exact-text, 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 `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. |
452
452
  | `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. |
453
+ | `li_save_post_draft` | `text` required, max 3000; `mentions` optional up to 25; `attachments` optional up to 20 exact images, max 5 MiB each and 12 MiB total; `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. |
454
+ | `li_post_drafts` | no arguments | List durable SignalDash drafts with attachment metadata, never base64, and return a fresh one-use calendar link. The calendar uses the scheduled-post card design for unscheduled SignalDash and Buffer drafts, with a visible `DRAFT` tag. Stored drafts remain separate from scheduled posts and never appear in `li_scheduled_posts.items`. |
455
+ | `li_update_post_draft` | `id`, complete replacement `text`, and current `expected_version` required; `mentions`, `attachments`, and `first_comment` optional | Atomically replace the complete saved draft. A stale version is refused with the current version and timestamp. Omitting an optional field clears it. No provider call occurs. |
456
+ | `li_delete_post_draft` | `id`, current `expected_version`, and `confirm:true` required | Permanently delete one exact saved SignalDash draft. Unknown, foreign, deleted, and replayed identifiers are indistinguishable. This cannot delete LinkedIn-native or scheduled content. |
457
+ | `li_post_draft_preview` | `id` required UUID | Create a short-lived, single-use `https://signaldash.dev/d/<token>` browser handoff for one exact saved draft. Its page and exact image bytes are protected by a tenant-, draft-, and originating-session-scoped cookie under `/drafts`. |
453
458
  | `li_scheduled_posts` | no arguments | Read one shared content-calendar view across SignalDash and the configured Buffer LinkedIn channel. Inspect `completeness` and every `sources.*.state` before treating absence as an empty calendar. Native LinkedIn scheduled posts and drafts are invisible because Unipile has no documented read route for them; SignalDash does not use raw Voyager routes or linkedin.com browser access. An empty `items` array proves only that the visible sources returned no entries. |
459
+ | `li_profile` | `identifier` required, a public id or member id | Read one LinkedIn profile: name, headline, public identifier, provider id, network distance and the profile picture url. Read-only, one provider call, and it returns those six fields rather than the provider's whole record about a person. It exists because pictures were otherwise a by-product of engagement only: reactions and comments carry one, the stored connections carry no picture field, chats carry none, and the invitation preview refuses anyone already connected, so every new name in a shortlist stayed faceless. |
460
+ | `sd_share_list` | `action` create/list/revoke, plus `title`, `columns`, `rows`, optional `note`, or `token` to revoke | Share one selection as a standalone `https://signaldash.dev/l/<token>` page that anyone with the link can open. `create` returns the url ONCE and stores only its SHA-256 hash, so a lost link can be revoked but never recovered. The page is a fixed snapshot and never re-reads the source, so a list shared today does not keep disclosing tomorrow's data. Pass `people` instead of `rows` for a shortlist of humans: each renders as a card with a photo where the provider supplies one and initials where it does not, an optional `score` out of 100, and the `reason` that earned it, with the scoring rule in `rubric` so the reader can check it. For a plain table, every row must have exactly as many cells as there are columns, because a crooked table reads as a claim about the wrong person. `list` shows every live link with its non-secret `list_id` and open count, so a link whose url is lost can still be switched off and a forgotten one stays visible; `revoke` takes either the `token` or that `list_id` and kills it immediately, and a revoked token is indistinguishable from one that never existed. |
454
461
  | `li_scheduled_post_preview` | `id` required UUID | Create a short-lived, single-use `https://signaldash.dev/p/<token>` browser handoff for one exact active SignalDash post. The isolated page includes its stored image attachments through account- and post-scoped protected media routes. |
455
- | `li_edit_scheduled_post` | `id`, `text`, and offset-qualified `scheduled_at` required; full replacement `mentions`, `attachments`, and `first_comment` optional; confirmation repeats the identical payload with `confirm:true`, `approval_hash`, `expected_version`, and `expected_updated_at` from preview | Atomically replace one scheduled post in place. The UUID, creation time, provider account, and content-pipeline audit fields remain unchanged. A changed or non-scheduled row is refused, approval is single-use and expires, and no provider call occurs. Omitting an optional replacement field clears it. Duplicate identity covers the complete normalized payload, including exact attachment bytes, and only active scheduled or executing rows participate; cancelled history never blocks a replacement. |
462
+ | `li_edit_scheduled_post` | `id`, `text`, and offset-qualified `scheduled_at` required; full replacement `mentions` (up to 25), image `attachments` (up to 20, max 5 MiB each and 12 MiB total), and `first_comment` optional; confirmation repeats the identical payload with `confirm:true`, `approval_hash`, `expected_version`, and `expected_updated_at` from preview | Atomically replace one scheduled post in place. The UUID, creation time, provider account, and content-pipeline audit fields remain unchanged. A changed or non-scheduled row is refused, approval is single-use and expires, and no provider call occurs. Omitting an optional replacement field clears it. Duplicate identity covers the complete normalized payload, including exact attachment bytes, and only active scheduled or executing rows participate; cancelled history never blocks a replacement. |
456
463
  | `li_cancel_scheduled_post` | `id` required UUID; `confirm:true` required | Cancel one exact post while it is still scheduled. It cannot recall an executing or published post. |
457
464
  | `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. |
458
465
  | `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. |
@@ -516,6 +523,11 @@ li_delete_message({"chat_id":"chat_li_7f3a","message_id":"msg_li_5c71","confirm"
516
523
  li_delete_comment({"post_id":"exact-v2-post-id","comment_id":"exact-own-comment-id","confirm":true})
517
524
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
518
525
  li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
526
+ li_save_post_draft({"text":"A durable idea with no publication date.","first_comment":"Source link goes here."})
527
+ li_post_drafts({})
528
+ li_update_post_draft({"id":"00000000-0000-4000-8000-000000000000","text":"Complete replacement copy.","expected_version":0})
529
+ li_post_draft_preview({"id":"00000000-0000-4000-8000-000000000000"})
530
+ li_delete_post_draft({"id":"00000000-0000-4000-8000-000000000000","expected_version":1,"confirm":true})
519
531
  li_scheduled_posts({})
520
532
  li_scheduled_post_preview({"id":"00000000-0000-4000-8000-000000000000"})
521
533
  li_edit_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","text":"Exact replacement","scheduled_at":"2026-09-07T08:00:00Z"})
@@ -568,6 +580,16 @@ comment failure never republishes the post. It fails interrupted or ambiguous
568
580
  executions closed and never retries them automatically. `email_send` cannot
569
581
  start a cold thread and must never be looped over recipients.
570
582
 
583
+ When the user wants to keep an arbitrary idea without choosing a publication
584
+ time, use `li_save_post_draft`, not `li_draft_post` with invented scheduling
585
+ arguments. Read it back with `li_post_drafts`; use its returned calendar link
586
+ for the combined read-only scheduled/draft view, or `li_post_draft_preview` for
587
+ one exact draft and its visuals. Updates are complete replacements and require
588
+ the current version, so preserve every optional field the user still wants and
589
+ omit only fields they explicitly want cleared. A saved draft does not grant
590
+ permission to schedule or publish it. Deletion requires explicit approval of
591
+ the exact draft and current version.
592
+
571
593
  ### Exact contact state and suppression
572
594
 
573
595
  `sd_contact_state` is local and account-scoped. It makes no Unipile, LinkedIn,
@@ -833,11 +855,12 @@ eligible to run. SignalDash limits are not LinkedIn-safe thresholds, and
833
855
  native LinkedIn activity can still race the final reads.
834
856
 
835
857
 
836
- ### The human-approved campaign loop
858
+ ### Human-approved campaigns
837
859
 
838
- A campaign is exactly one thing: a connection request, then the approved
839
- message once that person accepts. It is the only place SignalDash acts after an
840
- acceptance, and it acts only on the exact text a human read and approved.
860
+ A campaign sends a connection request and may stop there. When it has an
861
+ approved message sequence, SignalDash continues only after acceptance is
862
+ proven and acts only on the exact text a human read and approved. A connect-only
863
+ campaign never watches acceptance and completes after its invitations are sent.
841
864
 
842
865
  What the server proves, and refuses to assume:
843
866
 
@@ -866,16 +889,17 @@ What the server proves, and refuses to assume:
866
889
 
867
890
  Use the campaign tools in this order:
868
891
 
869
- 1. Get the exact people and the exact words from the human. Draft the messages
870
- in the user's own voice and keep them short: the on-acceptance group is two
871
- or three separate sends, never one block. Never invent a recipient or a
892
+ 1. Get the exact people and whether the campaign is connect-only. For a message
893
+ campaign, get the exact words from the human. Draft the messages in the
894
+ user's own voice and keep them short: the on-acceptance group is two or
895
+ three separate sends, never one block. Never invent a recipient or a
872
896
  sentence. Ask whether they already sent any of these people a connection
873
- request themselves; if so, mark that target `adopt_existing_invitation`
874
- instead of dropping them.
897
+ request themselves; for a message campaign, mark that target
898
+ `adopt_existing_invitation` instead of dropping them.
875
899
  2. Call `sd_campaign_create` once.
876
900
  3. Poll `sd_campaign_preview` until the campaign is `previewed` or terminal.
877
- Show the human every recipient, every exclusion and its reason, and the exact
878
- text of every message step.
901
+ Show the human every recipient, every exclusion and its reason, and either
902
+ the exact text of every message step or the explicit connect-only statement.
879
903
  4. Relay the returned `approval_url`. The human authenticates with the
880
904
  SignalDash invite credential, reads the rows, ticks the recipients they
881
905
  approve (none are preselected), and acknowledges the consequences. They then
@@ -884,8 +908,10 @@ Use the campaign tools in this order:
884
908
  bearer token cannot approve, and you cannot mint that code. Five failed
885
909
  credential attempts durably block that campaign's authentication surface for
886
910
  15 minutes.
887
- 5. Watch progress with `sd_campaign_status`. Report acceptances, replies,
888
- exclusions, and parse failures honestly, including people who never accepted.
911
+ 5. Watch progress with `sd_campaign_status`. A connect-only campaign completes
912
+ after its invitations are sent. For a message campaign, report acceptances,
913
+ replies, exclusions, and parse failures honestly, including people who never
914
+ accepted.
889
915
  6. To stop, inspect first, show the exact state and `approval_view_hash`, obtain
890
916
  cancellation approval, then call `sd_campaign_cancel` once with that hash and
891
917
  `confirm:true`.
@@ -918,14 +944,14 @@ them out. It inverts one check and nothing else:
918
944
  - at most 10 adopted targets per campaign: each one costs one profile read in
919
945
  the preview to resolve its provider id.
920
946
 
921
- Everything after that is identical to any other target. Acceptance is still
922
- proven only by a fresh profile read showing a connected network distance, the
923
- messages still need their own approval on the same page, the thread is still
924
- read before the first message, and anything in that thread the campaign did not
925
- send halts the sequence permanently. If the recipient answered the invitation
926
- note without accepting, that conversation already exists and the target is
927
- dropped with `existing_conversation`: it is a human conversation in progress,
928
- not a campaign to resume.
947
+ For a campaign with messages, everything after that is identical to any other
948
+ target. Acceptance is still proven only by a fresh profile read showing a
949
+ connected network distance, the messages still need their own approval on the
950
+ same page, the thread is still read before the first message, and anything in
951
+ that thread the campaign did not send halts the sequence permanently. If the
952
+ recipient answered the invitation note without accepting, that conversation
953
+ already exists and the target is dropped with `existing_conversation`: it is a
954
+ human conversation in progress, not a campaign to resume.
929
955
 
930
956
  The acceptance window (`invite_ttl_days`) for an adopted target is counted from
931
957
  the moment SignalDash adopts it, not from the hand-sent invitation, whose age
@@ -1366,8 +1392,9 @@ own-post engagement actions. An aggregate 75-attempt emergency brake bounds the
1366
1392
  combined lanes. Invitation sends also use the unchanged default 100-attempt
1367
1393
  weekly policy. Separate counters do not alter randomized action spacing, the
1368
1394
  single serialized provider queue, duplicate protection, or provider-warning
1369
- locks. An exact comment reply text already submitted by that sender on the same
1370
- UTC day is refused as `duplicate_comment_text`.
1395
+ locks. The same exact comment reply text is allowed up to ten times per sender
1396
+ per UTC day; the eleventh is refused as `duplicate_comment_text`. A true resend
1397
+ to the SAME comment stays blocked by the idempotency key regardless.
1371
1398
 
1372
1399
  WhatsApp and email share ONE separate daily bucket, and it is independent of
1373
1400
  the LinkedIn one: a WhatsApp message never spends LinkedIn capacity and a
@@ -88,8 +88,9 @@ On no, call `sd_secretary_reject` for that exact disposition and send nothing.
88
88
  - Let SignalDash serialize LinkedIn writes and apply action-specific jitter. Do
89
89
  not parallelize write calls or bypass the guard. Invitation jitter completes
90
90
  before the server's final provider preflight and action reservation.
91
- - Never reuse exact comment reply text during the same UTC day. SignalDash
92
- refuses it as `duplicate_comment_text`; changing punctuation only to evade
91
+ - The same exact comment reply text is allowed up to ten times per UTC day,
92
+ which covers a human replying briefly and repeatedly. The eleventh is
93
+ refused as `duplicate_comment_text`; changing punctuation only to evade
93
94
  that guard is prohibited.
94
95
  - Campaign invitations use a sender-local Monday-Friday 09:00-17:00 work
95
96
  window and a durable 90-180 second per-sender pacing interval. Timing is