@floomhq/signaldash 0.38.0 → 0.39.1

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.38.0 <invite-code>
123
+ npx -y @floomhq/signaldash@0.39.1 <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,12 +191,12 @@ 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()`
184
202
  - `li_cancel_scheduled_post(id, confirm)`
@@ -189,6 +207,16 @@ SignalDash exposes:
189
207
  Every operation runs through the hosted SignalDash backend. Agents never
190
208
  receive the Unipile access key.
191
209
 
210
+ When a user's content pipeline is enabled, a future post normally requires its
211
+ exact approved `content_pipeline_id`. A deliberate exception uses
212
+ `content_pipeline_override:{reason}` on the preview call, then repeats the
213
+ identical post, time, mentions, image bytes, first comment, and reason with
214
+ `confirm:true` plus the returned single-use `approval_hash`. The override never
215
+ disables the pipeline. Its reason, preview identity, and approval time are
216
+ stored atomically with the scheduled post and returned by
217
+ `li_scheduled_posts`. Override-approved payloads are immutable; their first
218
+ comment cannot be changed after scheduling.
219
+
192
220
  `li_start_chat` and `wa_start_chat` are separate tools because LinkedIn and
193
221
  WhatsApp expose different stable member-ID formats and spend different budget
194
222
  lanes. Both use the same safety contract. A first call previews 1 to 10 exact
@@ -227,6 +255,30 @@ readback. `sd_secretary_push_set` is the database kill switch; enabling starts
227
255
  from that instant so old rows do not create a backlog. The self-chat is never a
228
256
  fallback.
229
257
 
258
+ A top-level `li_post_comments` page reports each comment's `reply_counter` but
259
+ no reply objects, which left "did I already answer this?" unanswerable and kept
260
+ the comment loop switched off. `resolve_reply_state:true` reads each comment's
261
+ reply thread and attaches a `reply_state`. Presence and absence are proved to
262
+ different standards on purpose. Seeing an own reply proves `replied_by_me:true`
263
+ whatever the page's completeness, because presence on an incomplete page is
264
+ still presence. `replied_by_me:false` is emitted only when the reply set is
265
+ provably whole, every reply carries a resolvable `author_details.id`, ids are
266
+ unique, every reply belongs to this thread, and no reply is the account
267
+ owner's. Two proofs are accepted: the provider reporting the reply page
268
+ complete, or a zero `reply_counter` together with a reply read that came back
269
+ empty, which is the same pair the reply preflight already requires.
270
+ `replies_read === reply_counter` is deliberately NOT a proof; both numbers are
271
+ returned so a caller can see the corroboration, but SignalDash will not convert
272
+ it into a boolean, because a wrong `false` makes an agent talk over the account
273
+ owner in public. Everything else is `replied_by_me:null` with an explicit
274
+ reason. The flag is off by default and unresolved comments still carry
275
+ `reply_state.state = "unknown"`, so a miss is never mistaken for a proven
276
+ absence. Resolution is bounded per request by
277
+ `SIGNALDASH_REPLY_STATE_MAX_READS` (default 25); that bound is not a daily read
278
+ cap and does not claim to be one. A provider warning, 403 or 429 stops the
279
+ sweep at once and the whole call returns non-2xx carrying the partial evidence,
280
+ so a safety condition is never downgraded into a 200.
281
+
230
282
  `li_reply_to_comment` acts only on an inbound comment on the authenticated
231
283
  sender's own post. A preceding `li_post_comments` read records an exact
232
284
  per-comment watermark. Immediately before the write, SignalDash re-proves the
@@ -519,8 +571,11 @@ entries strictly one at a time, pauses `SIGNALDASH_WA_DELETE_PACE_MS` between
519
571
  them, and stops at `SIGNALDASH_WA_DELETE_BATCH_DEADLINE_MS`, returning the
520
572
  untouched remainder as `skipped` with `code: batch_deadline` so the caller can
521
573
  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.
574
+ for everyone and can answer successfully without removing anything. SignalDash
575
+ therefore re-reads the canonical provider message and returns success only when
576
+ `deleted: 1` or a genuine `404` proves it gone. A present row or failed
577
+ readback returns `502 deleted_unconfirmed`, records an unknown outcome, and is
578
+ not retryable.
524
579
 
525
580
  ## Deploying server changes
526
581
 
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" &&
@@ -351,6 +400,56 @@ const TOOLS = [
351
400
  additionalProperties: false,
352
401
  },
353
402
  },
403
+ {
404
+ name: "sd_content_pipeline_set", path: "/sd/content/set",
405
+ description: "Enable or disable the database-gated content pipeline. Enabling captures only new free text from the verified Secretary group. It never publishes or schedules a post.",
406
+ inputSchema: { type:"object",properties:{enabled:{type:"boolean"},confirm:{type:"boolean",const:true}},required:["enabled","confirm"],additionalProperties:false },
407
+ },
408
+ {
409
+ name: "sd_content_pipeline_list", path: "/sd/content/list",
410
+ description: "Read the durable idea to measured state machine with zero provider calls.",
411
+ inputSchema: { type:"object",properties:{state:{type:"string",enum:["all","idee","gepaart","entwurf","freigegeben","geplant","veroeffentlicht","gemessen"]},limit:{type:"integer",minimum:1,maximum:500}},additionalProperties:false },
412
+ },
413
+ {
414
+ name: "sd_content_pipeline_pair", path: "/sd/content/pair",
415
+ description: "Pair one Federico raw content input with one exact unused library post. Refuses arcs without stored source text, author, reactions, and comments.",
416
+ inputSchema: { type:"object",properties:{id:{type:"string"},arc_inspiration_id:{type:"string"},confirm:{type:"boolean",const:true}},required:["id","arc_inspiration_id","confirm"],additionalProperties:false },
417
+ },
418
+ {
419
+ name: "sd_content_pipeline_draft", path: "/sd/content/draft",
420
+ description: "Store one model-authored human-review draft with exactly one tested lever. Runs the executable fact gate and refuses silent weak-category drafts and unsupported agent numbers.",
421
+ inputSchema: { type:"object",properties:{id:{type:"string"},category:{type:"string",enum:["ereignis_mit_einsatz","innenschau"]},weak_category_decision:{type:"string",enum:["ereignis_vorangestellt","bewusst_reichweitenschwach"]},category_evidence:{type:"string",minLength:1,maxLength:500,description:"Required with ereignis_vorangestellt and must exactly start the draft."},tested_lever:{type:"string",minLength:1,maxLength:120},draft_text:{type:"string",minLength:1,maxLength:65536},numeric_claims:{type:"array",maxItems:100,items:{type:"object",properties:{value:{type:"string"},origin:{type:"string",enum:["federico_raw","agent"]},source:{type:"string"},confirmed_at:{type:"string"}},required:["value","origin"],additionalProperties:false}}},required:["id","category","tested_lever","draft_text"],additionalProperties:false },
422
+ },
423
+ {
424
+ name: "sd_content_pipeline_preview", path: "/sd/content/preview",
425
+ description: "Re-run the fact gate and render the exact reference metrics, Federico input, and finished draft as a PNG payload for the existing guarded WhatsApp attachment tool.",
426
+ inputSchema: { type:"object",properties:{id:{type:"string"}},required:["id"],additionalProperties:false },
427
+ },
428
+ {
429
+ name: "sd_content_pipeline_decide", path: "/sd/content/decide",
430
+ description: "Record Federico's exact yes, no, or correction response. Yes advances to approved but never schedules or publishes.",
431
+ inputSchema: { type:"object",properties:{id:{type:"string"},decision:{type:"string",enum:["yes","no","correction"]},correction:{type:"string"},decision_message_id:{type:"string",description:"Exact WhatsApp reply message id, when the decision came from the Secretary group."},confirm:{type:"boolean",const:true}},required:["id","decision","confirm"],additionalProperties:false },
432
+ },
433
+ {
434
+ name: "sd_content_pipeline_bind_schedule", path: "/sd/content/bind_schedule",
435
+ description: "Bind an already human-approved SignalDash scheduled post after a fresh complete SignalDash plus Buffer calendar read, fact gate, same-day, three-per-week, and active-post breathing checks.",
436
+ inputSchema: { type:"object",properties:{id:{type:"string"},scheduled_post_id:{type:"string"},scheduled_at:{type:"string",format:"date-time"},confirm:{type:"boolean",const:true}},required:["id","scheduled_post_id","scheduled_at","confirm"],additionalProperties:false },
437
+ },
438
+ {
439
+ name: "sd_content_pipeline_published", path: "/sd/content/published",
440
+ description: "Bind one planned item to exact provider publication evidence. This records evidence and publishes nothing.",
441
+ inputSchema: { type:"object",properties:{id:{type:"string"},post_urn:{type:"string",pattern:"^urn:li:(activity|ugcPost|share):[0-9]+$"},published_at:{type:"string",format:"date-time"},confirm:{type:"boolean",const:true}},required:["id","post_urn","published_at","confirm"],additionalProperties:false },
442
+ },
443
+ {
444
+ name: "sd_content_pipeline_measure", path: "/sd/content/measure",
445
+ description: "Store one due 24-hour or 72-hour provider measurement against Federico's own category baseline and emit a lever learning report at each five-post boundary.",
446
+ inputSchema: { type:"object",properties:{id:{type:"string"},window_hours:{type:"integer",enum:[24,72]},reactions:{type:"integer",minimum:0},comments:{type:"integer",minimum:0},impressions:{type:"integer",minimum:0},provider_evidence:{type:"object",properties:{source:{type:"string",const:"li_my_posts"},post_urn:{type:"string",pattern:"^urn:li:(activity|ugcPost|share):[0-9]+$"},captured_at:{type:"string",format:"date-time"},reactions:{type:"integer",minimum:0},comments:{type:"integer",minimum:0},impressions:{type:"integer",minimum:0}},required:["source","post_urn","captured_at","reactions","comments"],additionalProperties:true}},required:["id","window_hours","reactions","comments","provider_evidence"],additionalProperties:false },
447
+ },
448
+ {
449
+ name: "sd_content_board", path: "/sd/content/board",
450
+ description: "Generate the static board HTML from the same pipeline and stored shared-calendar rows. Makes zero provider calls.",
451
+ inputSchema: { type:"object",properties:{},additionalProperties:false },
452
+ },
354
453
  {
355
454
  name: "sd_inspiration_list",
356
455
  path: "/sd/inspiration/list",
@@ -1300,7 +1399,7 @@ const TOOLS = [
1300
1399
  {
1301
1400
  name: "wa_delete_message",
1302
1401
  path: "/wa/delete_message",
1303
- 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.",
1304
1403
  inputSchema: {
1305
1404
  type: "object",
1306
1405
  properties: {
@@ -1430,12 +1529,13 @@ const TOOLS = [
1430
1529
  {
1431
1530
  name: "li_post_comments",
1432
1531
  path: "/li/post_comments",
1433
- 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.",
1434
1533
  inputSchema: {
1435
1534
  type: "object",
1436
1535
  properties: {
1437
1536
  post_id: { type: "string", minLength: 1, maxLength: 500 },
1438
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." },
1439
1539
  limit: { type: "integer", minimum: 1, maximum: 100 },
1440
1540
  cursor: { type: "string", minLength: 1, maxLength: 4000 },
1441
1541
  },
@@ -1479,7 +1579,7 @@ const TOOLS = [
1479
1579
  {
1480
1580
  name: "li_delete_message",
1481
1581
  path: "/li/delete_message",
1482
- 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.",
1483
1583
  inputSchema: {
1484
1584
  type: "object",
1485
1585
  properties: {
@@ -1509,13 +1609,24 @@ const TOOLS = [
1509
1609
  {
1510
1610
  name: "li_draft_post",
1511
1611
  path: "/li/create_post",
1512
- 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.",
1513
1613
  inputSchema: {
1514
1614
  type: "object",
1515
1615
  properties: {
1516
1616
  text: { type: "string", minLength: 1, maxLength: 3000 },
1517
1617
  publish: { type: "boolean" },
1518
1618
  scheduled_at: { type: "string", format: "date-time" },
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
+ },
1519
1630
  first_comment: { type: "string", minLength: 1, maxLength: 1250 },
1520
1631
  mentions: {
1521
1632
  type: "array", maxItems: 20,
@@ -1662,6 +1773,64 @@ function mcpTool(name) {
1662
1773
  inputSchema: tool.inputSchema,
1663
1774
  };
1664
1775
  }
1776
+
1777
+ // The single source of truth for the tool catalog an agent can see, in full
1778
+ // (name, description, inputSchema) -- not just names. MCP `tools/list` and
1779
+ // the CLI `signaldash tools` both call this, so the two surfaces can never
1780
+ // drift: one entry dropped for a missing inputSchema (see mcpTool above)
1781
+ // disappears from both at once instead of only one of them.
1782
+ export function listTools() {
1783
+ return TOOLS.map(t => mcpTool(t.name)).filter(Boolean);
1784
+ }
1785
+
1786
+ // The one dispatcher behind every tool call, whichever door it came through.
1787
+ // MCP `tools/call` (stdio) and the CLI `call` command both resolve `name` in
1788
+ // this exact TOOLS lookup and hit this exact backend route with these exact
1789
+ // arguments -- nothing about a "second path" is a second implementation.
1790
+ // Every guard an agent depends on (write-control ledger, per-account budget,
1791
+ // read-before-send watermark, pacing/jitter, provider-warning lock, audit
1792
+ // trail) lives in server.cjs keyed off the route and the bearer token's
1793
+ // account, not off which client asked, so this function has no guard logic
1794
+ // of its own to keep in sync -- there is exactly one enforcement point, and
1795
+ // both callers share it. Returns `{ unknown: true }` for a name not in
1796
+ // TOOLS; callers translate that into their own transport's error shape
1797
+ // (MCP: JSON-RPC -32601, unchanged from before this refactor; CLI: a local
1798
+ // JSON error understood by scripts) so the shared function stays agnostic
1799
+ // to which door is asking.
1800
+ export async function callTool(name, args, dependencies = {}) {
1801
+ const request = dependencies.request || api;
1802
+ const t = TOOLS.find(x => x.name === name);
1803
+ if (!t) return { unknown: true };
1804
+ try {
1805
+ return await request(t.path || `/${t.ch}/${t.action}`, args || {});
1806
+ } catch (error) {
1807
+ // A rejected fetch (DNS failure, connection refused, timeout, TLS
1808
+ // error -- anything below the HTTP layer) has no status code and no
1809
+ // response body, and previously escaped uncaught: from the CLI that
1810
+ // broke the documented one-JSON-line/exit-1 contract with a raw stack
1811
+ // trace on stderr, and from MCP's stdio loop it would have crashed the
1812
+ // whole server on one bad network blip, taking every other tool down
1813
+ // with it for the rest of the session. 599 is the conventional
1814
+ // "no real HTTP response" pseudo-status other HTTP clients use for
1815
+ // exactly this case; it satisfies both callers' existing `status >= 300`
1816
+ // / `200 <= status < 300` checks without a third branch at either call
1817
+ // site, so a transport failure gets the same "this was not a success"
1818
+ // treatment as any other non-2xx response. Fails closed exactly like
1819
+ // fallbackApiError above: the outcome is unknown, not a clean failure
1820
+ // safe to retry into a possible duplicate.
1821
+ return {
1822
+ status: 599,
1823
+ json: {
1824
+ error: "SignalDash could not reach the backend. The action outcome is unknown; read the exact thread before any retry.",
1825
+ code: "request_failed",
1826
+ detail: error && error.message ? error.message : String(error),
1827
+ retryable: false,
1828
+ outcome_unknown: true,
1829
+ },
1830
+ };
1831
+ }
1832
+ }
1833
+
1665
1834
  export async function runMcp(dependencies = {}) {
1666
1835
  const input = dependencies.input || process.stdin;
1667
1836
  const output = dependencies.output || process.stdout;
@@ -1672,11 +1841,15 @@ export async function runMcp(dependencies = {}) {
1672
1841
  let msg; try { msg = JSON.parse(line); } catch { continue; }
1673
1842
  const { id, method, params } = msg;
1674
1843
  if (method === "initialize") reply(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "signaldash", version: PACKAGE_VERSION } });
1675
- else if (method === "tools/list") reply(id, { tools: TOOLS.map(t => mcpTool(t.name)).filter(Boolean) });
1844
+ else if (method === "tools/list") reply(id, { tools: listTools() });
1676
1845
  else if (method === "tools/call") {
1677
- const t = TOOLS.find(x => x.name === params.name);
1678
- if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
1679
- const r = await request(t.path || `/${t.ch}/${t.action}`, params.arguments || {});
1846
+ // Unknown-tool handling is preserved byte-for-byte from before this
1847
+ // refactor (JSON-RPC -32601, no content/isError envelope): callTool()
1848
+ // reports `unknown: true` instead of throwing so this branch can keep
1849
+ // exactly its old reply shape while the CLI's `call` command (below)
1850
+ // gets its own, different, JSON-on-stdout shape for the same case.
1851
+ const r = await callTool(params.name, params.arguments || {}, { request });
1852
+ if (r.unknown) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
1680
1853
  reply(id, { content: [{ type: "text", text: JSON.stringify(r.json) }], isError: r.status >= 300 });
1681
1854
  } else if (id !== undefined) reply(id, {});
1682
1855
  }
@@ -1778,8 +1951,11 @@ export async function cmdStatus(dependencies = {}) {
1778
1951
  for (const provider of ["linkedin", "whatsapp", "email"]) {
1779
1952
  const r = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
1780
1953
  const ok = r.status === 200 && r.json.connected;
1954
+ const unavailable = r.json?.code === "upstream_authentication_failed"
1955
+ ? `authentication failed (provider HTTP ${r.json.upstream_status || "unknown"})`
1956
+ : "not connected";
1781
1957
  log(" " + (ok ? chalk.green("+") : chalk.dim("-")) + " " + provider.padEnd(9) +
1782
- (ok ? chalk.dim(r.json.name || "connected") : chalk.dim("not connected")));
1958
+ (ok ? chalk.dim(r.json.name || "connected") : chalk.dim(unavailable)));
1783
1959
  }
1784
1960
  log("");
1785
1961
  }
@@ -1821,6 +1997,90 @@ export async function cmdConnections(outPath, dependencies = {}) {
1821
1997
  }
1822
1998
 
1823
1999
 
2000
+ // Serializes exactly one JSON value with a trailing newline, and nothing
2001
+ // else -- no chalk, no ora, no progress text. Both `call` and `tools` write
2002
+ // through this so a script piping the CLI's stdout never has to skip
2003
+ // decorative lines to find the JSON.
2004
+ function jsonLine(value) {
2005
+ return `${JSON.stringify(value)}\n`;
2006
+ }
2007
+
2008
+ async function readAll(stream) {
2009
+ const chunks = [];
2010
+ for await (const chunk of stream) {
2011
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
2012
+ }
2013
+ return Buffer.concat(chunks).toString("utf8");
2014
+ }
2015
+
2016
+ // The second sanctioned path from issue #100: when an agent session's tool
2017
+ // roster does not include the MCP `li_*`/`wa_*`/`sd_*` tools (server
2018
+ // connected, but the client never loaded them), this CLI verb reaches the
2019
+ // exact same catalog and the exact same backend routes through callTool()
2020
+ // above, so it carries every guard the MCP path carries -- there is nothing
2021
+ // else to carry, because callTool() IS the MCP path's dispatcher too.
2022
+ //
2023
+ // Deliberately machine-facing, not human-facing: `signaldash status` and
2024
+ // friends print colored, human-readable lines; `call` and `tools` print
2025
+ // exactly one JSON value to stdout and communicate everything else (success,
2026
+ // refusal, malformed input) through the exit code, so an agent can script
2027
+ // against them without scraping prose.
2028
+ export async function cmdCall(argv, dependencies = {}) {
2029
+ const request = dependencies.request || api;
2030
+ const input = dependencies.input || process.stdin;
2031
+ const write = dependencies.write || (text => process.stdout.write(text));
2032
+ const [name, ...rest] = argv;
2033
+ if (!name || rest.length > 1) {
2034
+ write(jsonLine({ error: "usage: signaldash call <tool_name> ['<json-args>']", code: "usage" }));
2035
+ process.exitCode = 1;
2036
+ return;
2037
+ }
2038
+ let raw;
2039
+ if (rest.length === 1) {
2040
+ raw = rest[0];
2041
+ } else {
2042
+ // No inline JSON argument: read stdin to EOF when it is piped (a real
2043
+ // agent invocation), but never block on a live TTY waiting for input
2044
+ // nobody is going to type -- an empty/absent body means `{}`.
2045
+ raw = input.isTTY ? "" : await readAll(input);
2046
+ }
2047
+ const trimmed = (raw || "").trim();
2048
+ let args = {};
2049
+ if (trimmed) {
2050
+ let parsed;
2051
+ try {
2052
+ parsed = JSON.parse(trimmed);
2053
+ } catch {
2054
+ write(jsonLine({ error: "arguments must be valid JSON", code: "invalid_json" }));
2055
+ process.exitCode = 1;
2056
+ return;
2057
+ }
2058
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2059
+ write(jsonLine({ error: "arguments must be a JSON object", code: "invalid_json" }));
2060
+ process.exitCode = 1;
2061
+ return;
2062
+ }
2063
+ args = parsed;
2064
+ }
2065
+ const r = await callTool(name, args, { request });
2066
+ if (r.unknown) {
2067
+ write(jsonLine({ error: "unknown tool", code: "unknown_tool", tool: name }));
2068
+ process.exitCode = 1;
2069
+ return;
2070
+ }
2071
+ write(jsonLine(r.json));
2072
+ process.exitCode = r.status >= 200 && r.status < 300 ? 0 : 1;
2073
+ }
2074
+
2075
+ // The exact same catalog `tools/list` gives an MCP client, in full (name,
2076
+ // description, inputSchema) -- so an agent using `call` can discover the
2077
+ // right tool name and argument shape without needing the MCP transport
2078
+ // loaded at all.
2079
+ export async function cmdTools(dependencies = {}) {
2080
+ const write = dependencies.write || (text => process.stdout.write(text));
2081
+ write(jsonLine(listTools()));
2082
+ }
2083
+
1824
2084
  function printHelp(log = console.log) {
1825
2085
  log(`SignalDash \u2014 secure LinkedIn, WhatsApp and email access for your AI agent.
1826
2086
 
@@ -1831,6 +2091,14 @@ function printHelp(log = console.log) {
1831
2091
  signaldash connections [file.csv] export your LinkedIn connections
1832
2092
  signaldash skill install the agent skill
1833
2093
  signaldash mcp run the MCP server (used by your agent)
2094
+ signaldash tools list every tool as JSON (name, description, inputSchema)
2095
+ signaldash call <tool> ['<json-args>']
2096
+ call one tool directly -- same routes and
2097
+ guards as your agent's MCP tools. A second
2098
+ sanctioned path for when MCP tools are not
2099
+ loaded in this session; reads args from the
2100
+ given JSON or from stdin, prints one JSON
2101
+ line, exits 0 on HTTP 2xx else 1.
1834
2102
  signaldash logout revoke this device
1835
2103
 
1836
2104
  Your channel credentials stay on the SignalDash server. They are never stored on
@@ -1852,6 +2120,8 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
1852
2120
  else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
1853
2121
  else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
1854
2122
  else if (cmd === "skill") await cmdSkill(dependencies);
2123
+ else if (cmd === "tools") await cmdTools(dependencies);
2124
+ else if (cmd === "call") await cmdCall(argv.slice(1), dependencies);
1855
2125
  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; }
1856
2126
  else printHelp(log);
1857
2127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "Secure LinkedIn, WhatsApp, and email access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "node --test",
21
- "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/buffer-calendar.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/write-control.cjs && node --check server/secretary-store.cjs && node --check server/secretary-collector.cjs && node --check server/secretary-latest.cjs && node --check server/secretary-approvals.cjs && node --check server/secretary-push.cjs && node --check server/secretary-rules.cjs && node --check server/secretary-shadow-gate.cjs && node --check server/secretary-vault-importer.cjs && node --check server/secretary-inspiration.cjs && node --check server/linkedin-archive-importer.cjs && node --check server/voice-profile.cjs && node --check server/campaign-store.cjs && node --check server/campaign-runner.cjs && node --check server/withdrawal-store.cjs && node --check server/post-scheduler.cjs && node --check server/message-scheduler.cjs && node --check scripts/import-linkedin-archive.mjs && node --check scripts/import-secretary-vault-snapshot.mjs && node --check scripts/rebuild-secretary-actors.mjs && node --check scripts/extract-conversation-inspiration.mjs && node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/config-file.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
21
+ "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check server/content-pipeline.cjs && node --check server/buffer-calendar.cjs && node --check server/composio-email.cjs && node --check server/attachments.cjs && node --check server/write-control.cjs && node --check server/secretary-store.cjs && node --check server/secretary-collector.cjs && node --check server/secretary-latest.cjs && node --check server/secretary-approvals.cjs && node --check server/secretary-push.cjs && node --check server/secretary-rules.cjs && node --check server/secretary-shadow-gate.cjs && node --check server/secretary-vault-importer.cjs && node --check server/secretary-inspiration.cjs && node --check server/linkedin-archive-importer.cjs && node --check server/voice-profile.cjs && node --check server/campaign-store.cjs && node --check server/campaign-runner.cjs && node --check server/withdrawal-store.cjs && node --check server/post-scheduler.cjs && node --check server/message-scheduler.cjs && node --check scripts/import-linkedin-archive.mjs && node --check scripts/import-secretary-vault-snapshot.mjs && node --check scripts/rebuild-secretary-actors.mjs && node --check scripts/extract-conversation-inspiration.mjs && node --check scripts/render-content-board.mjs && node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/config-file.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
22
22
  },
23
23
  "keywords": [
24
24
  "mcp",
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: content
3
+ description: Turn Federico's raw content ideas into measured LinkedIn experiments through the SignalDash content pipeline. Load before writing any post for Federico.
4
+ ---
5
+
6
+ # Federico content pipeline
7
+
8
+ Use SignalDash states in order: `idee`, `gepaart`, `entwurf`, `freigegeben`,
9
+ `geplant`, `veroeffentlicht`, `gemessen`. Never skip a state. Never publish or
10
+ schedule without Federico approving the exact final text and time.
11
+
12
+ ## Rules that decide the draft
13
+
14
+ - Category beats form. Lead with an event carrying real stakes. Introspection
15
+ either gets an event first or is labelled `bewusst_reichweitenschwach`.
16
+ - Word count is noise. Federico's top and bottom groups both average 10.2
17
+ words. Do not optimize length as a reach lever.
18
+ - Every post has exactly two inputs: Federico's own provable material and one
19
+ foreign library post with stored author, exact text, reactions, and comments.
20
+ An unproved arc is refused.
21
+ - Change exactly one tested lever per post. Store it with the draft.
22
+ - Measure at 24 and 72 hours against Federico's category baseline, never a
23
+ foreign benchmark: events with stakes 139 to 435 reactions, introspection 7
24
+ to 39.
25
+ - Treat question versus opinion endings and reply timing as hypotheses, not
26
+ rules. A test changes one of them while the rest stays stable.
27
+
28
+ ## Operating flow
29
+
30
+ 1. Read `sd_content_pipeline_list` and `sd_inspiration_list`.
31
+ 2. Pair the idea with one evidenced unused arc using
32
+ `sd_content_pipeline_pair`.
33
+ 3. Draft in Federico's raw voice. Call `sd_content_pipeline_draft` with one
34
+ category and one lever. For `innenschau`, pass the exact opening event as
35
+ `category_evidence` or mark it `bewusst_reichweitenschwach`. SignalDash
36
+ verifies the opening and renders either choice. The server then runs
37
+ `/root/secretary-build/fact_gate.py`. A refusal stops the flow.
38
+ 4. Call `sd_content_pipeline_preview`. Send its exact PNG to the returned
39
+ Secretary `chat_id` through the existing WhatsApp read and attachment path.
40
+ Do not send a link or file reference. Federico replies yes, no, or with one
41
+ correction.
42
+ 5. Record the response with `sd_content_pipeline_decide`, including the exact
43
+ WhatsApp `decision_message_id`. A correction returns the item to `gepaart`,
44
+ stays visible on the item, and requires a new fact-gated draft and image.
45
+ 6. Before planning, call `li_scheduled_posts` and inspect SignalDash, Buffer,
46
+ and native LinkedIn completeness. Create the exact approved schedule only
47
+ through `li_draft_post` with the exact `content_pipeline_id`. SignalDash
48
+ binds it in the same request only after the preflight passes. Same-day
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.
51
+ 7. Record exact publication evidence, then read the live own post at 24 and 72
52
+ hours and pass its reactions, comments, impressions, and provider evidence
53
+ to `sd_content_pipeline_measure`. The evidence must name `li_my_posts`, the
54
+ exact post URN and capture time, and repeat the exact submitted metrics.
55
+
56
+ Federico-origin numbers that the fact gate does not know need one simple
57
+ confirmation and a dated source row in `/root/secretary-build/FAKTEN.md`.
58
+ Agent-added numbers without a source are removed, never rationalized.
@@ -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
 
@@ -370,6 +387,16 @@ Use the exact tool names and argument keys below. Limits are optional.
370
387
  | `sd_inspiration_import` | `source` exact LinkedIn post URL or URN; optional source author, Federico note, purpose, content kind, credit fields, and format description | Migration/manual capture through Unipile's documented post retrieval endpoint. The primary capture path remains pasting a URL into the verified Secretary WhatsApp group. |
371
388
  | `sd_inspiration_channel_configure` | exact `chat_id`, `provider_id`, `chat_name`, and `confirm:true` | Re-prove the connected WhatsApp account and exact group in the live chat list, then enable automatic URL capture and same-group acknowledgements. Never substitute a self-chat. |
372
389
  | `sd_inspiration_channel_set` | `capture_enabled` and `confirm:true` | Disable or re-enable capture and acknowledgements through the database kill switch without a deploy. |
390
+ | `sd_content_pipeline_set` | `enabled` and `confirm:true` | Enable capture from this instant or disable the complete content pipeline through its database flag. It never publishes. |
391
+ | `sd_content_pipeline_list` | optional `state` and `limit` | Read every durable stage from idea through measured with zero provider calls. |
392
+ | `sd_content_pipeline_pair` | `id`, exact `arc_inspiration_id`, `confirm:true` | Bind Federico raw material to one complete unused foreign post carrying real author, text, reaction, and comment evidence. |
393
+ | `sd_content_pipeline_draft` | `id`, category, one `tested_lever`, exact `draft_text`; weak-category decision, exact opening `category_evidence`, and numeric claims when applicable | Verify an asserted event is the actual draft opening, then run the executable fact and authorship gates before a draft is stored. |
394
+ | `sd_content_pipeline_preview` | `id` | Re-run the fact gate and render the exact arc metrics, content input, and draft as a PNG for the guarded WhatsApp attachment path. |
395
+ | `sd_content_pipeline_decide` | `id`, `decision`, optional correction, exact WhatsApp `decision_message_id`, `confirm:true` | Record Federico's yes, no, or correction. A correction returns to paired state for a new fact-gated image. Yes approves but does not schedule or publish. |
396
+ | `sd_content_pipeline_bind_schedule` | `id`, `scheduled_post_id`, `scheduled_at`, `confirm:true` | Bind an existing approved SignalDash schedule after fresh SignalDash plus Buffer calendar, frequency, same-day, fact, and breathing gates. |
397
+ | `sd_content_pipeline_published` | `id`, exact post URN and publish time, `confirm:true` | Record publication evidence without publishing anything. |
398
+ | `sd_content_pipeline_measure` | `id`, 24 or 72 hours, live metrics, provider evidence | Compare the live result with Federico's category baseline and emit lever learning every five posts. |
399
+ | `sd_content_board` | no arguments | Render board HTML from the same pipeline and stored shared-calendar rows with zero provider calls. |
373
400
  | `sd_secretary_approve` | `disposition_id` required; `payload_hash` required exact 64-character hash from latest; `confirm:true` required | Create one single-use, 15-minute receipt for the exact current stored draft. It derives the sender and executable payload from storage. |
374
401
  | `sd_secretary_reject` | `disposition_id` required; `confirm:true` required | Reject one exact current stored draft. It sends nothing. |
375
402
  | `li_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500; `unread` optional boolean | Find recent LinkedIn chats, unread counts, and exact `chat_id` values. `unread:true` is forwarded to the provider as `unread=true`; SignalDash does not fetch a full chat page and filter it locally. Search accepts a unique stored display name, exact `public_id`, member id, or ordinary chat field. A stored name/public id is resolved to its verified member id before matching, so a provider row with `name:null` remains findable. Without an explicit `max_scan`, a zero-match search extends from 200 up to a hard 500-chat/five-page bound; explicit bounds remain exact. Read `scanned_chats`, `pages_fetched`, `scan_limit`, and `exhaustive` before concluding absence. The response also reports any resolved public/member ids. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter` rather than accepted and ignored. |
@@ -409,19 +436,19 @@ Use the exact tool names and argument keys below. Limits are optional.
409
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. |
410
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. |
411
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. |
412
- | `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. |
413
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. |
414
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. |
415
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. |
416
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`. |
417
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. |
418
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. |
419
- | `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`. |
420
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. |
421
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. |
422
- | `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. |
423
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. |
424
- | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `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. A schedule is stored only with `publish:true` after approval of the exact text, time, mentions, images, and comment. |
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. |
425
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. |
426
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. |
427
454
  | `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. |
@@ -517,7 +544,14 @@ approval, repeat the identical payload with `publish:true`. Verify the stored
517
544
  record with `li_scheduled_posts`. Before scheduling, read that shared view and
518
545
  inspect its `completeness` plus the SignalDash, Buffer, and native LinkedIn
519
546
  source states. A date conflict in either visible source blocks planning until
520
- the human resolves it. Native LinkedIn remains explicitly blind, so never call
547
+ the human resolves it. If the human explicitly approves one exception, call
548
+ the preview again with `content_pipeline_override:{reason}`, show the returned
549
+ exact payload, time, comment, reason, and expiry, then repeat that identical
550
+ payload once with `publish:true`, `confirm:true`, and its `approval_hash`. Never
551
+ generate an override silently, reuse its single-use hash, change its reason, or
552
+ disable the pipeline as a shortcut. The schedule and approval consumption
553
+ commit atomically. Once scheduled, its override-bound first comment is
554
+ immutable. Native LinkedIn remains explicitly blind, so never call
521
555
  an empty result an empty calendar. For an existing scheduled post, use
522
556
  `li_set_scheduled_post_first_comment` only after approval of the exact comment.
523
557
  SignalDash persists the published post ID before sending the comment, so a
@@ -694,6 +728,16 @@ never retried and never causes the post to be published again. Use
694
728
  requires a fresh list, the exact id, approval, and
695
729
  `confirm:true`; it works only while state is `scheduled`.
696
730
 
731
+ An explicit `content_pipeline_override` is scoped to one exact scheduled
732
+ payload and expires after 15 minutes. The preview hash binds the post text,
733
+ offset-qualified instant, mention set, image filenames/types/bytes, first
734
+ comment, and exact reason. It bypasses only the enabled content pipeline's
735
+ idea/arc/fact/calendar/frequency/breathing preflight for that one schedule. It
736
+ commits atomically with the schedule, and its bound first comment cannot be
737
+ edited afterward. It
738
+ does not bypass sender resolution, duplicate protection, provider warnings,
739
+ locks, budgets, publication ordering, or first-comment failure handling.
740
+
697
741
  Everything SignalDash can schedule is one exact thing at one exact time:
698
742
  `li_draft_post` for a post, `sd_schedule_message` for a message. There is no
699
743
  recurring schedule, no automatic follow-up, no acceptance-triggered message and
@@ -1256,6 +1300,25 @@ Comply:
1256
1300
  Any upstream 429, provider warning, checkpoint, restriction, unusual-activity
1257
1301
  prompt, or HTTP 403 also means stop. Do not retry.
1258
1302
 
1303
+ ### 502 `upstream_authentication_failed`
1304
+
1305
+ Meaning: the provider definitively rejected authentication. The result includes
1306
+ `upstream_status` (401 or 403) and is distinct from `outcome_unknown`; a 401 is
1307
+ not reported as a timeout. Stop sends on that channel and reconnect or escalate
1308
+ the provider credential. `signaldash status` reports the channel as unavailable
1309
+ after a send-path 401. An explicit new account claim clears the recorded failure;
1310
+ a later confirmed WhatsApp or email send also clears it. LinkedIn 401 durably
1311
+ locks its sender, and `sd_budget_status` reports that lock with zero actions
1312
+ available now.
1313
+
1314
+ Every provider failure from a send includes a `request_id` that is also present
1315
+ in the server log beside only the operation and channel. Use it for operator
1316
+ correlation; no chat identifier, account identifier, recipient, or message text
1317
+ is logged. If an intermediary removes an error body, the MCP client reconstructs
1318
+ the safe classification from response headers. With no usable body or headers it
1319
+ returns `backend_error_response_unreadable` with `outcome_unknown:true`, never
1320
+ an empty object and never permission to retry.
1321
+
1259
1322
  ## Before every send
1260
1323
 
1261
1324
  These rules apply to LinkedIn messages and invitations, WhatsApp, email, and
@@ -1437,9 +1500,10 @@ If the user then asks to take that message back:
1437
1500
  wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
1438
1501
  ```
1439
1502
 
1440
- 3. Read the chat again. WhatsApp enforces its own time and role limits and can
1441
- answer a delete successfully without removing anything, so a 200 is not
1442
- proof. Only the re-read is.
1503
+ 3. Require `deleted:true` and `readback_verified:true` in the result. SignalDash
1504
+ performs the provider re-read itself. `502 deleted_unconfirmed` means the
1505
+ provider accepted the delete but the readback did not prove it gone; do not
1506
+ retry that message.
1443
1507
  4. Never delete a message the user did not name. `403 message_not_own` means
1444
1508
  the message is the other person's and cannot be removed by anyone,
1445
1509
  including a group admin. `409 duplicate_delete` means SignalDash already