@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 +60 -5
- package/bin/sd.mjs +281 -11
- package/package.json +2 -2
- package/skills/content/SKILL.md +58 -0
- package/skills/signaldash/SKILL.md +73 -9
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.
|
|
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
|
|
523
|
-
the
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
1844
|
+
else if (method === "tools/list") reply(id, { tools: listTools() });
|
|
1676
1845
|
else if (method === "tools/call") {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|