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