@floomhq/signaldash 0.39.0 → 0.39.2

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
@@ -91,6 +91,24 @@ The MCP server uses the user token created by `login`. It cannot access a
91
91
  LinkedIn, WhatsApp, or email account until that channel has been connected for
92
92
  the same logged-in user.
93
93
 
94
+ ## Without an MCP client
95
+
96
+ If the agent session cannot load the MCP tools (server connected, tools not
97
+ in the roster) the CLI reaches the exact same tool catalog through the exact
98
+ same backend routes and guards, no MCP transport required:
99
+
100
+ ```bash
101
+ npx -y @floomhq/signaldash tools # full catalog: name, description, inputSchema
102
+ npx -y @floomhq/signaldash call li_list_chats '{"limit":5}' # dispatch one tool directly
103
+ ```
104
+
105
+ `call` prints one JSON line to stdout and exits `0` on an HTTP 2xx, `1`
106
+ otherwise (including local argument validation failures, which never reach
107
+ the backend). It is not a fallback that skips anything: it is the same
108
+ dispatcher the MCP `tools/call` handler uses, so the write-control ledger,
109
+ per-account budgets, read-before-send, pacing, provider-warning lock, and
110
+ audit trail all run identically.
111
+
94
112
  ## Operating skill distribution
95
113
 
96
114
  The canonical [`signaldash` skill](skills/signaldash/SKILL.md) teaches an agent
@@ -102,7 +120,7 @@ setup installs both from the same pinned npm package
102
120
  the human chose to execute:
103
121
 
104
122
  ```bash
105
- npx -y @floomhq/signaldash@0.39.0 <invite-code>
123
+ npx -y @floomhq/signaldash@0.39.2 <invite-code>
106
124
  ```
107
125
 
108
126
  Run that command in a terminal, not in an agent chat. Do not ask an agent to
@@ -173,14 +191,15 @@ SignalDash exposes:
173
191
  - `email_send(to, subject, body)`
174
192
  - `li_my_posts(limit, member_id)`
175
193
  - `li_post_reactions(post_id, limit, cursor?)`
176
- - `li_post_comments(post_id, comment_id?, limit, cursor?)`
194
+ - `li_post_comments(post_id, comment_id?, resolve_reply_state?, limit, cursor?)`
177
195
  - `li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, expected_watermark?, secretary_receipt_id?)`
178
196
  - `li_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)`
179
197
  - `li_delete_message(chat_id, message_id, confirm)`
180
198
  - `li_delete_comment(post_id, comment_id, confirm)`
181
- - `li_draft_post(text, publish, scheduled_at?, mentions?, attachments?, first_comment?)`
199
+ - `li_draft_post(text, publish, scheduled_at?, content_pipeline_id?, content_pipeline_override?, mentions?, attachments?, first_comment?)`
182
200
  - `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
183
201
  - `li_scheduled_posts()`
202
+ - `li_scheduled_post_preview(id)`
184
203
  - `li_cancel_scheduled_post(id, confirm)`
185
204
  - `sd_schedule_message(channel, chat_id, text, scheduled_at, confirm)`
186
205
  - `sd_scheduled_messages(state?, channel?)`
@@ -189,6 +208,16 @@ SignalDash exposes:
189
208
  Every operation runs through the hosted SignalDash backend. Agents never
190
209
  receive the Unipile access key.
191
210
 
211
+ When a user's content pipeline is enabled, a future post normally requires its
212
+ exact approved `content_pipeline_id`. A deliberate exception uses
213
+ `content_pipeline_override:{reason}` on the preview call, then repeats the
214
+ identical post, time, mentions, image bytes, first comment, and reason with
215
+ `confirm:true` plus the returned single-use `approval_hash`. The override never
216
+ disables the pipeline. Its reason, preview identity, and approval time are
217
+ stored atomically with the scheduled post and returned by
218
+ `li_scheduled_posts`. Override-approved payloads are immutable; their first
219
+ comment cannot be changed after scheduling.
220
+
192
221
  `li_start_chat` and `wa_start_chat` are separate tools because LinkedIn and
193
222
  WhatsApp expose different stable member-ID formats and spend different budget
194
223
  lanes. Both use the same safety contract. A first call previews 1 to 10 exact
@@ -227,6 +256,30 @@ readback. `sd_secretary_push_set` is the database kill switch; enabling starts
227
256
  from that instant so old rows do not create a backlog. The self-chat is never a
228
257
  fallback.
229
258
 
259
+ A top-level `li_post_comments` page reports each comment's `reply_counter` but
260
+ no reply objects, which left "did I already answer this?" unanswerable and kept
261
+ the comment loop switched off. `resolve_reply_state:true` reads each comment's
262
+ reply thread and attaches a `reply_state`. Presence and absence are proved to
263
+ different standards on purpose. Seeing an own reply proves `replied_by_me:true`
264
+ whatever the page's completeness, because presence on an incomplete page is
265
+ still presence. `replied_by_me:false` is emitted only when the reply set is
266
+ provably whole, every reply carries a resolvable `author_details.id`, ids are
267
+ unique, every reply belongs to this thread, and no reply is the account
268
+ owner's. Two proofs are accepted: the provider reporting the reply page
269
+ complete, or a zero `reply_counter` together with a reply read that came back
270
+ empty, which is the same pair the reply preflight already requires.
271
+ `replies_read === reply_counter` is deliberately NOT a proof; both numbers are
272
+ returned so a caller can see the corroboration, but SignalDash will not convert
273
+ it into a boolean, because a wrong `false` makes an agent talk over the account
274
+ owner in public. Everything else is `replied_by_me:null` with an explicit
275
+ reason. The flag is off by default and unresolved comments still carry
276
+ `reply_state.state = "unknown"`, so a miss is never mistaken for a proven
277
+ absence. Resolution is bounded per request by
278
+ `SIGNALDASH_REPLY_STATE_MAX_READS` (default 25); that bound is not a daily read
279
+ cap and does not claim to be one. A provider warning, 403 or 429 stops the
280
+ sweep at once and the whole call returns non-2xx carrying the partial evidence,
281
+ so a safety condition is never downgraded into a 200.
282
+
230
283
  `li_reply_to_comment` acts only on an inbound comment on the authenticated
231
284
  sender's own post. A preceding `li_post_comments` read records an exact
232
285
  per-comment watermark. Immediately before the write, SignalDash re-proves the
@@ -335,6 +388,12 @@ linkedin.com to fill the gap. An empty item list therefore means only that the
335
388
  visible SignalDash and Buffer sources are empty, never that the native LinkedIn
336
389
  calendar is empty.
337
390
 
391
+ `li_scheduled_post_preview` returns a short-lived, single-use browser handoff
392
+ for one exact active SignalDash post owned by the authenticated user. The page
393
+ shows only that post, including its stored image attachments. Preview sessions
394
+ and media routes remain account- and post-scoped, expire independently, and
395
+ are revoked on logout.
396
+
338
397
  `sd_schedule_message` puts one exact message into one exact chat at one exact
339
398
  time, on WhatsApp or LinkedIn, text only. It enqueues; it never sends. The
340
399
  attachment bytes a WhatsApp send accepts are refused here rather than held on
@@ -519,8 +578,11 @@ entries strictly one at a time, pauses `SIGNALDASH_WA_DELETE_PACE_MS` between
519
578
  them, and stops at `SIGNALDASH_WA_DELETE_BATCH_DEADLINE_MS`, returning the
520
579
  untouched remainder as `skipped` with `code: batch_deadline` so the caller can
521
580
  resume exactly those. WhatsApp applies its own time and role limits to deleting
522
- for everyone and can answer successfully without removing anything, so re-read
523
- the chat to confirm.
581
+ for everyone and can answer successfully without removing anything. SignalDash
582
+ therefore re-reads the canonical provider message and returns success only when
583
+ `deleted: 1` or a genuine `404` proves it gone. A present row or failed
584
+ readback returns `502 deleted_unconfirmed`, records an unknown outcome, and is
585
+ not retryable.
524
586
 
525
587
  ## Deploying server changes
526
588
 
package/bin/sd.mjs CHANGED
@@ -59,7 +59,37 @@ function updateCfg(update) {
59
59
  return updateConfigFile(configPaths().file, update);
60
60
  }
61
61
 
62
- async function api(
62
+ function fallbackApiError(response) {
63
+ const header = name => response.headers?.get?.(name) || null;
64
+ const declaredCode = header("x-signaldash-error-code");
65
+ const upstreamStatusHeader = header("x-signaldash-upstream-status");
66
+ const declaredUpstreamStatus = upstreamStatusHeader === null
67
+ ? null
68
+ : Number(upstreamStatusHeader);
69
+ const requestId = header("x-signaldash-request-id");
70
+ const upstreamStatus = Number.isInteger(declaredUpstreamStatus)
71
+ ? declaredUpstreamStatus
72
+ : null;
73
+ const messages = {
74
+ upstream_authentication_failed:
75
+ "Messaging provider authentication is unavailable.",
76
+ outcome_unknown:
77
+ "The provider outcome is unknown. Read the exact thread before any retry.",
78
+ };
79
+ const code = declaredCode || "backend_error_response_unreadable";
80
+ return {
81
+ error: messages[code]
82
+ || "SignalDash returned an unreadable error response. The action outcome is unknown; read the exact thread before any retry.",
83
+ code,
84
+ http_status: response.status,
85
+ ...(upstreamStatus !== null ? { upstream_status: upstreamStatus } : {}),
86
+ ...(requestId ? { request_id: requestId } : {}),
87
+ retryable: false,
88
+ ...(declaredCode ? {} : { outcome_unknown: true }),
89
+ };
90
+ }
91
+
92
+ export async function api(
63
93
  path,
64
94
  body,
65
95
  { auth = true, method = "POST", backend, token } = {},
@@ -75,7 +105,26 @@ async function api(
75
105
  },
76
106
  ...(method === "GET" ? {} : { body: JSON.stringify(body || {}) }),
77
107
  });
78
- const json = await r.json().catch(() => ({}));
108
+ let json = await r.json().catch(() => null);
109
+ if (
110
+ r.status >= 300 &&
111
+ (
112
+ !json ||
113
+ typeof json !== "object" ||
114
+ Array.isArray(json) ||
115
+ Object.keys(json).length === 0
116
+ )
117
+ ) {
118
+ // An intermediary can discard or replace a backend error body. Returning
119
+ // `{}` here erased the only facts an MCP caller can use to distinguish a
120
+ // definite provider rejection from a send whose delivery is unknown.
121
+ // The backend repeats its safe classification in headers; when even those
122
+ // are absent, fail closed as outcome-unknown rather than imply a clean
123
+ // failure that an agent may retry into a duplicate.
124
+ json = fallbackApiError(r);
125
+ } else if (json === null) {
126
+ json = {};
127
+ }
79
128
  if (
80
129
  json &&
81
130
  typeof json === "object" &&
@@ -1350,7 +1399,7 @@ const TOOLS = [
1350
1399
  {
1351
1400
  name: "wa_delete_message",
1352
1401
  path: "/wa/delete_message",
1353
- description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so re-read the chat afterwards to confirm. Deletes spend their own daily budget and never consume your send budget.",
1402
+ description: "Delete one WhatsApp message this account SENT, in a chat this account owns. Read the chat first: the exact `message_id` comes from `wa_read_messages`. Only your own messages can be deleted; someone else's is refused with `403 message_not_own`. This is irreversible and is never retried: a delete already recorded for this exact chat and message is refused with `409 duplicate_delete` rather than replayed. SignalDash re-reads after the provider accepts the delete and returns success only when `deleted:1` or a genuine 404 proves the message gone; otherwise it returns `502 deleted_unconfirmed` and refuses a retry. Deletes spend their own daily budget and never consume your send budget.",
1354
1403
  inputSchema: {
1355
1404
  type: "object",
1356
1405
  properties: {
@@ -1480,12 +1529,13 @@ const TOOLS = [
1480
1529
  {
1481
1530
  name: "li_post_comments",
1482
1531
  path: "/li/post_comments",
1483
- description: "Read one bounded page of comments and authors on a post. Pass comment_id to read replies to that exact parent comment. Pass the exact social_id returned by li_my_posts; a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash never guesses it from li_my_posts.",
1532
+ description: "Read one bounded page of comments and authors on a post. Pass comment_id to read replies to that exact parent comment. Pass resolve_reply_state:true to also learn, per comment, whether you already replied: each comment then carries reply_state with replied_by_me true, false, or null. Only treat a comment as unanswered when reply_state.state is no_replies or answered_by_others; null means unknown and must never be read as nobody-replied. Pass the exact social_id returned by li_my_posts; a numeric id is resolved against your own recent posts when possible. Read completeness.state, completeness.total, and completeness.next_cursor before treating the list as complete, then pass cursor to continue. The true total comes from the provider response when available; SignalDash never guesses it from li_my_posts.",
1484
1533
  inputSchema: {
1485
1534
  type: "object",
1486
1535
  properties: {
1487
1536
  post_id: { type: "string", minLength: 1, maxLength: 500 },
1488
1537
  comment_id: { type: "string", minLength: 1, maxLength: 500, description: "Optional exact parent comment id. When present, returns replies to that comment." },
1538
+ resolve_reply_state: { type: "boolean", description: "Read the connected account identity once and each comment's reply thread to prove whether you already replied. Reply-thread reads are bounded per request. Cannot be combined with comment_id. When omitted every comment still carries reply_state with state unknown, so a miss is never mistaken for a proven absence." },
1489
1539
  limit: { type: "integer", minimum: 1, maximum: 100 },
1490
1540
  cursor: { type: "string", minLength: 1, maxLength: 4000 },
1491
1541
  },
@@ -1529,7 +1579,7 @@ const TOOLS = [
1529
1579
  {
1530
1580
  name: "li_delete_message",
1531
1581
  path: "/li/delete_message",
1532
- description: "Remediate one exact LinkedIn message sent by this authenticated account, only within the provider's 60-minute window. Requires exact chat and message identity plus confirm:true. SignalDash proves chat ownership, exact-chat membership, own authorship, timestamp eligibility, a separate remediation budget, and post-delete absence. Deletion cannot undo prior delivery, reading, or notifications and does not weaken any send gate.",
1582
+ description: "Remediate one exact LinkedIn message sent by this authenticated account, only within the provider's 60-minute window. Requires exact chat and message identity plus confirm:true. SignalDash proves chat ownership, exact-chat membership, own authorship, timestamp eligibility, a separate remediation budget, and the provider's post-delete state. Success requires `deleted:1` or a genuine 404; otherwise the tool returns `502 deleted_unconfirmed`, locks the sender, and never retries automatically. Deletion cannot undo prior delivery, reading, or notifications and does not weaken any send gate.",
1533
1583
  inputSchema: {
1534
1584
  type: "object",
1535
1585
  properties: {
@@ -1559,7 +1609,7 @@ const TOOLS = [
1559
1609
  {
1560
1610
  name: "li_draft_post",
1561
1611
  path: "/li/create_post",
1562
- description: "Draft, publish, or schedule a LinkedIn post. Scheduling requires an offset-qualified scheduled_at plus publish:true after exact human approval. Optional mentions, base64 image attachments, and an account-owner-authored first_comment are preserved for the scheduled publish.",
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.",
1563
1613
  inputSchema: {
1564
1614
  type: "object",
1565
1615
  properties: {
@@ -1567,6 +1617,16 @@ const TOOLS = [
1567
1617
  publish: { type: "boolean" },
1568
1618
  scheduled_at: { type: "string", format: "date-time" },
1569
1619
  content_pipeline_id: { type: "string", minLength: 1, maxLength: 200 },
1620
+ content_pipeline_override: {
1621
+ type: "object",
1622
+ properties: {
1623
+ reason: { type: "string", minLength: 8, maxLength: 500 },
1624
+ confirm: { type: "boolean", const: true },
1625
+ approval_hash: { type: "string", minLength: 64, maxLength: 64, pattern: "^[0-9a-f]{64}$" },
1626
+ },
1627
+ required: ["reason"],
1628
+ additionalProperties: false,
1629
+ },
1570
1630
  first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1571
1631
  mentions: {
1572
1632
  type: "array", maxItems: 20,
@@ -1617,6 +1677,19 @@ const TOOLS = [
1617
1677
  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.",
1618
1678
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1619
1679
  },
1680
+ {
1681
+ name: "li_scheduled_post_preview",
1682
+ path: "/li/scheduled_post_preview",
1683
+ description: "Create a short-lived one-use browser link for one exact active SignalDash scheduled post. The preview renders only that post, its stored image attachments, and its queued first comment in a read-only session. Requires the exact SignalDash post UUID returned by li_draft_post or li_scheduled_posts.",
1684
+ inputSchema: {
1685
+ type: "object",
1686
+ properties: {
1687
+ id: { type: "string", format: "uuid" },
1688
+ },
1689
+ required: ["id"],
1690
+ additionalProperties: false,
1691
+ },
1692
+ },
1620
1693
  {
1621
1694
  name: "li_cancel_scheduled_post",
1622
1695
  path: "/li/cancel_scheduled_post",
@@ -1713,6 +1786,64 @@ function mcpTool(name) {
1713
1786
  inputSchema: tool.inputSchema,
1714
1787
  };
1715
1788
  }
1789
+
1790
+ // The single source of truth for the tool catalog an agent can see, in full
1791
+ // (name, description, inputSchema) -- not just names. MCP `tools/list` and
1792
+ // the CLI `signaldash tools` both call this, so the two surfaces can never
1793
+ // drift: one entry dropped for a missing inputSchema (see mcpTool above)
1794
+ // disappears from both at once instead of only one of them.
1795
+ export function listTools() {
1796
+ return TOOLS.map(t => mcpTool(t.name)).filter(Boolean);
1797
+ }
1798
+
1799
+ // The one dispatcher behind every tool call, whichever door it came through.
1800
+ // MCP `tools/call` (stdio) and the CLI `call` command both resolve `name` in
1801
+ // this exact TOOLS lookup and hit this exact backend route with these exact
1802
+ // arguments -- nothing about a "second path" is a second implementation.
1803
+ // Every guard an agent depends on (write-control ledger, per-account budget,
1804
+ // read-before-send watermark, pacing/jitter, provider-warning lock, audit
1805
+ // trail) lives in server.cjs keyed off the route and the bearer token's
1806
+ // account, not off which client asked, so this function has no guard logic
1807
+ // of its own to keep in sync -- there is exactly one enforcement point, and
1808
+ // both callers share it. Returns `{ unknown: true }` for a name not in
1809
+ // TOOLS; callers translate that into their own transport's error shape
1810
+ // (MCP: JSON-RPC -32601, unchanged from before this refactor; CLI: a local
1811
+ // JSON error understood by scripts) so the shared function stays agnostic
1812
+ // to which door is asking.
1813
+ export async function callTool(name, args, dependencies = {}) {
1814
+ const request = dependencies.request || api;
1815
+ const t = TOOLS.find(x => x.name === name);
1816
+ if (!t) return { unknown: true };
1817
+ try {
1818
+ return await request(t.path || `/${t.ch}/${t.action}`, args || {});
1819
+ } catch (error) {
1820
+ // A rejected fetch (DNS failure, connection refused, timeout, TLS
1821
+ // error -- anything below the HTTP layer) has no status code and no
1822
+ // response body, and previously escaped uncaught: from the CLI that
1823
+ // broke the documented one-JSON-line/exit-1 contract with a raw stack
1824
+ // trace on stderr, and from MCP's stdio loop it would have crashed the
1825
+ // whole server on one bad network blip, taking every other tool down
1826
+ // with it for the rest of the session. 599 is the conventional
1827
+ // "no real HTTP response" pseudo-status other HTTP clients use for
1828
+ // exactly this case; it satisfies both callers' existing `status >= 300`
1829
+ // / `200 <= status < 300` checks without a third branch at either call
1830
+ // site, so a transport failure gets the same "this was not a success"
1831
+ // treatment as any other non-2xx response. Fails closed exactly like
1832
+ // fallbackApiError above: the outcome is unknown, not a clean failure
1833
+ // safe to retry into a possible duplicate.
1834
+ return {
1835
+ status: 599,
1836
+ json: {
1837
+ error: "SignalDash could not reach the backend. The action outcome is unknown; read the exact thread before any retry.",
1838
+ code: "request_failed",
1839
+ detail: error && error.message ? error.message : String(error),
1840
+ retryable: false,
1841
+ outcome_unknown: true,
1842
+ },
1843
+ };
1844
+ }
1845
+ }
1846
+
1716
1847
  export async function runMcp(dependencies = {}) {
1717
1848
  const input = dependencies.input || process.stdin;
1718
1849
  const output = dependencies.output || process.stdout;
@@ -1723,11 +1854,15 @@ export async function runMcp(dependencies = {}) {
1723
1854
  let msg; try { msg = JSON.parse(line); } catch { continue; }
1724
1855
  const { id, method, params } = msg;
1725
1856
  if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
1726
- else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)).filter(Boolean) });
1857
+ else if (method === "tools/list") reply(id, { tools: listTools() });
1727
1858
  else if (method === "tools/call") {
1728
- const t = TOOLS.find(x => x.name === params.name);
1729
- if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
1730
- const r = await request(t.path || `/${t.ch}/${t.action}`, params.arguments || {});
1859
+ // Unknown-tool handling is preserved byte-for-byte from before this
1860
+ // refactor (JSON-RPC -32601, no content/isError envelope): callTool()
1861
+ // reports `unknown: true` instead of throwing so this branch can keep
1862
+ // exactly its old reply shape while the CLI's `call` command (below)
1863
+ // gets its own, different, JSON-on-stdout shape for the same case.
1864
+ const r = await callTool(params.name, params.arguments || {}, { request });
1865
+ if (r.unknown) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
1731
1866
  reply(id, { content: [{ type: "text", text: JSON.stringify(r.json) }], isError: r.status >= 300 });
1732
1867
  } else if (id !== undefined) reply(id, {});
1733
1868
  }
@@ -1829,8 +1964,11 @@ export async function cmdStatus(dependencies = {}) {
1829
1964
  for (const provider of ["linkedin", "whatsapp", "email"]) {
1830
1965
  const r = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
1831
1966
  const ok = r.status === 200 && r.json.connected;
1967
+ const unavailable = r.json?.code === "upstream_authentication_failed"
1968
+ ? `authentication failed (provider HTTP ${r.json.upstream_status || "unknown"})`
1969
+ : "not connected";
1832
1970
  log(" " + (ok ? chalk.green("+") : chalk.dim("-")) + " " + provider.padEnd(9) +
1833
- (ok ? chalk.dim(r.json.name || "connected") : chalk.dim("not connected")));
1971
+ (ok ? chalk.dim(r.json.name || "connected") : chalk.dim(unavailable)));
1834
1972
  }
1835
1973
  log("");
1836
1974
  }
@@ -1872,6 +2010,90 @@ export async function cmdConnections(outPath, dependencies = {}) {
1872
2010
  }
1873
2011
 
1874
2012
 
2013
+ // Serializes exactly one JSON value with a trailing newline, and nothing
2014
+ // else -- no chalk, no ora, no progress text. Both `call` and `tools` write
2015
+ // through this so a script piping the CLI's stdout never has to skip
2016
+ // decorative lines to find the JSON.
2017
+ function jsonLine(value) {
2018
+ return `${JSON.stringify(value)}\n`;
2019
+ }
2020
+
2021
+ async function readAll(stream) {
2022
+ const chunks = [];
2023
+ for await (const chunk of stream) {
2024
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
2025
+ }
2026
+ return Buffer.concat(chunks).toString("utf8");
2027
+ }
2028
+
2029
+ // The second sanctioned path from issue #100: when an agent session's tool
2030
+ // roster does not include the MCP `li_*`/`wa_*`/`sd_*` tools (server
2031
+ // connected, but the client never loaded them), this CLI verb reaches the
2032
+ // exact same catalog and the exact same backend routes through callTool()
2033
+ // above, so it carries every guard the MCP path carries -- there is nothing
2034
+ // else to carry, because callTool() IS the MCP path's dispatcher too.
2035
+ //
2036
+ // Deliberately machine-facing, not human-facing: `signaldash status` and
2037
+ // friends print colored, human-readable lines; `call` and `tools` print
2038
+ // exactly one JSON value to stdout and communicate everything else (success,
2039
+ // refusal, malformed input) through the exit code, so an agent can script
2040
+ // against them without scraping prose.
2041
+ export async function cmdCall(argv, dependencies = {}) {
2042
+ const request = dependencies.request || api;
2043
+ const input = dependencies.input || process.stdin;
2044
+ const write = dependencies.write || (text => process.stdout.write(text));
2045
+ const [name, ...rest] = argv;
2046
+ if (!name || rest.length > 1) {
2047
+ write(jsonLine({ error: "usage: signaldash call <tool_name> ['<json-args>']", code: "usage" }));
2048
+ process.exitCode = 1;
2049
+ return;
2050
+ }
2051
+ let raw;
2052
+ if (rest.length === 1) {
2053
+ raw = rest[0];
2054
+ } else {
2055
+ // No inline JSON argument: read stdin to EOF when it is piped (a real
2056
+ // agent invocation), but never block on a live TTY waiting for input
2057
+ // nobody is going to type -- an empty/absent body means `{}`.
2058
+ raw = input.isTTY ? "" : await readAll(input);
2059
+ }
2060
+ const trimmed = (raw || "").trim();
2061
+ let args = {};
2062
+ if (trimmed) {
2063
+ let parsed;
2064
+ try {
2065
+ parsed = JSON.parse(trimmed);
2066
+ } catch {
2067
+ write(jsonLine({ error: "arguments must be valid JSON", code: "invalid_json" }));
2068
+ process.exitCode = 1;
2069
+ return;
2070
+ }
2071
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2072
+ write(jsonLine({ error: "arguments must be a JSON object", code: "invalid_json" }));
2073
+ process.exitCode = 1;
2074
+ return;
2075
+ }
2076
+ args = parsed;
2077
+ }
2078
+ const r = await callTool(name, args, { request });
2079
+ if (r.unknown) {
2080
+ write(jsonLine({ error: "unknown tool", code: "unknown_tool", tool: name }));
2081
+ process.exitCode = 1;
2082
+ return;
2083
+ }
2084
+ write(jsonLine(r.json));
2085
+ process.exitCode = r.status >= 200 && r.status < 300 ? 0 : 1;
2086
+ }
2087
+
2088
+ // The exact same catalog `tools/list` gives an MCP client, in full (name,
2089
+ // description, inputSchema) -- so an agent using `call` can discover the
2090
+ // right tool name and argument shape without needing the MCP transport
2091
+ // loaded at all.
2092
+ export async function cmdTools(dependencies = {}) {
2093
+ const write = dependencies.write || (text => process.stdout.write(text));
2094
+ write(jsonLine(listTools()));
2095
+ }
2096
+
1875
2097
  function printHelp(log = console.log) {
1876
2098
  log(`SignalDash \u2014 secure LinkedIn, WhatsApp and email access for your AI agent.
1877
2099
 
@@ -1882,6 +2104,14 @@ function printHelp(log = console.log) {
1882
2104
  signaldash connections [file.csv] export your LinkedIn connections
1883
2105
  signaldash skill install the agent skill
1884
2106
  signaldash mcp run the MCP server (used by your agent)
2107
+ signaldash tools list every tool as JSON (name, description, inputSchema)
2108
+ signaldash call <tool> ['<json-args>']
2109
+ call one tool directly -- same routes and
2110
+ guards as your agent's MCP tools. A second
2111
+ sanctioned path for when MCP tools are not
2112
+ loaded in this session; reads args from the
2113
+ given JSON or from stdin, prints one JSON
2114
+ line, exits 0 on HTTP 2xx else 1.
1885
2115
  signaldash logout revoke this device
1886
2116
 
1887
2117
  Your channel credentials stay on the SignalDash server. They are never stored on
@@ -1903,6 +2133,8 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
1903
2133
  else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
1904
2134
  else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
1905
2135
  else if (cmd === "skill") await cmdSkill(dependencies);
2136
+ else if (cmd === "tools") await cmdTools(dependencies);
2137
+ else if (cmd === "call") await cmdCall(argv.slice(1), dependencies);
1906
2138
  else if (cmd && !["help","--help","-h"].includes(cmd) && !INVITE_CODE_REGEX.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
1907
2139
  else printHelp(log);
1908
2140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.39.0",
3
+ "version": "0.39.2",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,9 @@ schedule without Federico approving the exact final text and time.
47
47
  through `li_draft_post` with the exact `content_pipeline_id`. SignalDash
48
48
  binds it in the same request only after the preflight passes. Same-day
49
49
  conflicts, three posts in one week, and a post above baseline inside its
50
- two-day breathing window refuse before a schedule row exists.
50
+ two-day breathing window refuse before a schedule row exists. When Federico
51
+ asks to inspect the stored result, call `li_scheduled_post_preview` with its
52
+ exact schedule ID and share the isolated one-post handoff.
51
53
  7. Record exact publication evidence, then read the live own post at 24 and 72
52
54
  hours and pass its reactions, comments, impressions, and provider evidence
53
55
  to `sd_content_pipeline_measure`. The evidence must name `li_my_posts`, the
@@ -299,7 +299,24 @@ li_list_chats({"limit": 5})
299
299
 
300
300
  If the tools are missing but `status` is connected, the account is ready and
301
301
  the MCP client is not loaded. Fix the MCP registration or restart the client;
302
- do not reconnect the account.
302
+ do not reconnect the account. **This is not a reason to use Unipile, the
303
+ provider, or any other path around SignalDash.** If restarting the client is
304
+ not possible in this session, use the CLI directly instead: it is the same
305
+ account, the same backend, and the same guards, only a different door.
306
+
307
+ ```bash
308
+ npx -y @floomhq/signaldash tools
309
+ npx -y @floomhq/signaldash call li_list_chats '{"limit": 5}'
310
+ ```
311
+
312
+ `call` dispatches through the identical route and argument shape as the MCP
313
+ tool of the same name -- the write-control ledger, per-account budget,
314
+ read-before-send, pacing, provider-warning lock, and audit trail all run
315
+ exactly as they do for an MCP call, because both go through the same backend
316
+ route. `tools` lists the full catalog (name, description, `inputSchema`) as
317
+ JSON so the right tool name and argument shape are discoverable without MCP.
318
+ `call` prints exactly one JSON line to stdout and exits 0 on success, 1
319
+ otherwise; script against the exit code and that one line, not prose.
303
320
 
304
321
  ## Numbered workflow for every account task
305
322
 
@@ -419,21 +436,22 @@ Use the exact tool names and argument keys below. Limits are optional.
419
436
  | `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. |
420
437
  | `wa_start_chat` | `member_ids` required array of 1-10 exact `@s.whatsapp.net` or `@lid` 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 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. |
421
438
  | `wa_send_message` | `chat_id` required; `text` optional only when a file is attached, max 5000 characters; `attachments` optional array of up to 4 exact `{filename, content_type, content_base64}` files, at most 16 MiB per file and 16 MiB per message, types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, `text/csv`, `text/plain`, `application/json`, `application/zip`, xlsx; `expected_watermark` optional exact 64-character watermark; `mark_read` optional boolean, default false | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. Own outbound additions do not invalidate the read; inbound additions or mutations return `new_messages`, `changed_kind`, and `current_watermark`. Pass `expected_watermark` to bind the send to the exact reviewed state and `mark_read:true` only when the approved workflow also calls for clearing unread after the confirmed send. A file spends the same daily send budget and is recorded the same way as a text message; there is no separate attachment budget. A call carrying neither text nor an attachment is refused with `400 text_or_attachment_required`. Attachments are checked before anything is reserved, so a refusal costs no send: `400 unsupported_attachment_type`, `400 attachment_too_large`, `400 attachments_too_large`, `400 too_many_attachments`, `400 malformed_attachment_base64`, `400 invalid_attachment_filename`, and `413 request_too_large` when the whole body is too big to read. Nothing is ever truncated or dropped silently. The same caption with the same file is refused as `409 duplicate_send`; the same caption with a different file is a different message and goes through. If a send times out or the provider never confirms it, the message may still have been delivered: an identical retry is refused with `409 send_outcome_unknown`. Read the chat AGAIN, and ONLY if the message is genuinely absent, resend the identical payload with `confirm_resend:true`. The re-read is enforced, not advisory: a `confirm_resend` whose most recent read of that chat predates the failed attempt is refused with `428 reread_after_failed_send_required`, because a read taken before the attempt cannot show whether the message arrived. A re-read failure remains `502 thread_preflight_unavailable`, and a legacy proof with no watermark remains `428 read_before_send_required`; neither costs send budget. LinkedIn messages carry text only. |
422
- | `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so read the chat again to confirm the message is gone. |
439
+ | `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. |
423
440
  | `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. |
424
441
  | `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. |
425
442
  | `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. Everyone on `cc` counts as a participant, so a read authorizes them too. The newest messages are returned, not the oldest, so the people being replied to are always in the window. |
426
443
  | `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998, single line; `body` required, max 5000; `thread_id` optional, max 500 | Send one approved email to a participant in a recently read existing thread. Pass the `thread_id` you read to reply inside that thread; omit it only when starting a new one. A blank or oversized `thread_id` is refused with `400 invalid_request` rather than quietly starting a new thread beside the original. The same subject and body to the same person in the same thread is refused as `409 duplicate_send`; the same words in a different thread are a different message and go through. A subject containing a line break is refused with `400 invalid_request`, because a subject is one header line; a body with line breaks is normal and sends. If a send leaves this host and the provider never answers, the retry is refused as `409 send_outcome_unknown`: read the thread again, and only if the email is genuinely absent resend with `confirm_resend: true`. |
427
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. |
428
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. |
429
- | `li_post_comments` | `post_id` required; `comment_id` optional exact parent id; use the exact `social_id` from `li_my_posts`; `limit` default 50, max 100; `cursor` optional | Read one bounded page of comments, or pass `comment_id` to read replies to that exact parent. Always inspect `completeness.state`, `total`, and `next_cursor`; continue with the cursor while state is `incomplete`. Numeric ids are resolved against the user's own recent posts when possible. The provider total is reported when available and is never guessed from `li_my_posts`. |
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`. |
430
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. |
431
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. |
432
- | `li_delete_message` | `chat_id`, `message_id`, and `confirm:true` required | Remediate one exact own LinkedIn message within 60 minutes of sending. SignalDash proves account, exact chat, own authorship, timestamp eligibility, a separate remediation budget, and post-delete absence. This cannot undo delivery, reading, or notifications and never relaxes a send gate. |
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. |
433
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. |
434
- | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `content_pipeline_id` required for scheduling while that user's pipeline is enabled; `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 runs its fact, exact-text, shared-calendar, frequency, and breathing preflight before the schedule row is created. |
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. |
435
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. |
436
453
  | `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. |
454
+ | `li_scheduled_post_preview` | `id` required UUID | Create a short-lived, single-use browser handoff for one exact active SignalDash post. The isolated page includes its stored image attachments through account- and post-scoped protected media routes. |
437
455
  | `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. |
438
456
  | `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. |
439
457
  | `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. |
@@ -498,6 +516,7 @@ li_delete_comment({"post_id":"exact-v2-post-id","comment_id":"exact-own-comment-
498
516
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
499
517
  li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
500
518
  li_scheduled_posts({})
519
+ li_scheduled_post_preview({"id":"00000000-0000-4000-8000-000000000000"})
501
520
  li_cancel_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
502
521
  sd_schedule_message({"channel":"whatsapp","chat_id":"chat_wa_91b2","text":"Following up on the Q3 numbers, as promised.","scheduled_at":"2026-08-19T09:00:00Z","confirm":true})
503
522
  sd_scheduled_messages({})
@@ -527,8 +546,17 @@ approval, repeat the identical payload with `publish:true`. Verify the stored
527
546
  record with `li_scheduled_posts`. Before scheduling, read that shared view and
528
547
  inspect its `completeness` plus the SignalDash, Buffer, and native LinkedIn
529
548
  source states. A date conflict in either visible source blocks planning until
530
- the human resolves it. Native LinkedIn remains explicitly blind, so never call
549
+ the human resolves it. If the human explicitly approves one exception, call
550
+ the preview again with `content_pipeline_override:{reason}`, show the returned
551
+ exact payload, time, comment, reason, and expiry, then repeat that identical
552
+ payload once with `publish:true`, `confirm:true`, and its `approval_hash`. Never
553
+ generate an override silently, reuse its single-use hash, change its reason, or
554
+ disable the pipeline as a shortcut. The schedule and approval consumption
555
+ commit atomically. Once scheduled, its override-bound first comment is
556
+ immutable. Native LinkedIn remains explicitly blind, so never call
531
557
  an empty result an empty calendar. For an existing scheduled post, use
558
+ `li_scheduled_post_preview` when the human wants to review the exact stored
559
+ copy and images in isolation. Use
532
560
  `li_set_scheduled_post_first_comment` only after approval of the exact comment.
533
561
  SignalDash persists the published post ID before sending the comment, so a
534
562
  comment failure never republishes the post. It fails interrupted or ambiguous
@@ -704,6 +732,16 @@ never retried and never causes the post to be published again. Use
704
732
  requires a fresh list, the exact id, approval, and
705
733
  `confirm:true`; it works only while state is `scheduled`.
706
734
 
735
+ An explicit `content_pipeline_override` is scoped to one exact scheduled
736
+ payload and expires after 15 minutes. The preview hash binds the post text,
737
+ offset-qualified instant, mention set, image filenames/types/bytes, first
738
+ comment, and exact reason. It bypasses only the enabled content pipeline's
739
+ idea/arc/fact/calendar/frequency/breathing preflight for that one schedule. It
740
+ commits atomically with the schedule, and its bound first comment cannot be
741
+ edited afterward. It
742
+ does not bypass sender resolution, duplicate protection, provider warnings,
743
+ locks, budgets, publication ordering, or first-comment failure handling.
744
+
707
745
  Everything SignalDash can schedule is one exact thing at one exact time:
708
746
  `li_draft_post` for a post, `sd_schedule_message` for a message. There is no
709
747
  recurring schedule, no automatic follow-up, no acceptance-triggered message and
@@ -1266,6 +1304,25 @@ Comply:
1266
1304
  Any upstream 429, provider warning, checkpoint, restriction, unusual-activity
1267
1305
  prompt, or HTTP 403 also means stop. Do not retry.
1268
1306
 
1307
+ ### 502 `upstream_authentication_failed`
1308
+
1309
+ Meaning: the provider definitively rejected authentication. The result includes
1310
+ `upstream_status` (401 or 403) and is distinct from `outcome_unknown`; a 401 is
1311
+ not reported as a timeout. Stop sends on that channel and reconnect or escalate
1312
+ the provider credential. `signaldash status` reports the channel as unavailable
1313
+ after a send-path 401. An explicit new account claim clears the recorded failure;
1314
+ a later confirmed WhatsApp or email send also clears it. LinkedIn 401 durably
1315
+ locks its sender, and `sd_budget_status` reports that lock with zero actions
1316
+ available now.
1317
+
1318
+ Every provider failure from a send includes a `request_id` that is also present
1319
+ in the server log beside only the operation and channel. Use it for operator
1320
+ correlation; no chat identifier, account identifier, recipient, or message text
1321
+ is logged. If an intermediary removes an error body, the MCP client reconstructs
1322
+ the safe classification from response headers. With no usable body or headers it
1323
+ returns `backend_error_response_unreadable` with `outcome_unknown:true`, never
1324
+ an empty object and never permission to retry.
1325
+
1269
1326
  ## Before every send
1270
1327
 
1271
1328
  These rules apply to LinkedIn messages and invitations, WhatsApp, email, and
@@ -1447,9 +1504,10 @@ If the user then asks to take that message back:
1447
1504
  wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
1448
1505
  ```
1449
1506
 
1450
- 3. Read the chat again. WhatsApp enforces its own time and role limits and can
1451
- answer a delete successfully without removing anything, so a 200 is not
1452
- proof. Only the re-read is.
1507
+ 3. Require `deleted:true` and `readback_verified:true` in the result. SignalDash
1508
+ performs the provider re-read itself. `502 deleted_unconfirmed` means the
1509
+ provider accepted the delete but the readback did not prove it gone; do not
1510
+ retry that message.
1453
1511
  4. Never delete a message the user did not name. `403 message_not_own` means
1454
1512
  the message is the other person's and cannot be removed by anyone,
1455
1513
  including a group admin. `409 duplicate_delete` means SignalDash already