@floomhq/signaldash 0.27.0 → 0.28.0
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 +100 -2
- package/bin/sd.mjs +177 -18
- package/lib/cli.js +1 -1
- package/lib/skill-template.cjs +32 -0
- package/package.json +2 -2
- package/skills/signaldash/SKILL.md +145 -17
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx -y @floomhq/signaldash connect linkedin claim <account_id>
|
|
|
52
52
|
Paste this command into a terminal:
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
claude mcp add signaldash -- npx -y @floomhq/signaldash mcp
|
|
55
|
+
claude mcp add signaldash -s user -- npx -y @floomhq/signaldash mcp
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
### Cursor
|
|
@@ -91,7 +91,7 @@ flows. The one-command setup installs the skill from the same pinned npm package
|
|
|
91
91
|
the human chose to execute:
|
|
92
92
|
|
|
93
93
|
```bash
|
|
94
|
-
npx -y @floomhq/signaldash@0.
|
|
94
|
+
npx -y @floomhq/signaldash@0.28.0 <invite-code>
|
|
95
95
|
```
|
|
96
96
|
|
|
97
97
|
Run that command in a terminal, not in an agent chat. Do not ask an agent to
|
|
@@ -153,6 +153,9 @@ SignalDash exposes:
|
|
|
153
153
|
- `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
|
|
154
154
|
- `li_scheduled_posts()`
|
|
155
155
|
- `li_cancel_scheduled_post(id, confirm)`
|
|
156
|
+
- `sd_schedule_message(channel, chat_id, text, scheduled_at, confirm)`
|
|
157
|
+
- `sd_scheduled_messages(state?, channel?)`
|
|
158
|
+
- `sd_cancel_scheduled_message(id, confirm)`
|
|
156
159
|
|
|
157
160
|
Every operation runs through the hosted SignalDash backend. Agents never
|
|
158
161
|
receive the Unipile access key.
|
|
@@ -171,6 +174,31 @@ every refusal names the exact rule and the exact file that tripped it. Nothing
|
|
|
171
174
|
is ever truncated or dropped silently. LinkedIn messages carry text only, and
|
|
172
175
|
`li_send_message` refuses an `attachments` argument instead of ignoring it.
|
|
173
176
|
|
|
177
|
+
A recent read is no longer sufficient on its own. A message send re-reads the
|
|
178
|
+
thread immediately before sending and refuses with `409 thread_changed` unless
|
|
179
|
+
it is still the exact thread that was read, which is the guard LinkedIn has
|
|
180
|
+
always had and WhatsApp did not: a 30-minute read window is satisfied just as
|
|
181
|
+
comfortably by a conversation that moved 29 minutes ago as by one nobody has
|
|
182
|
+
touched, and a reply that arrived in between is precisely when a draft stops
|
|
183
|
+
being the right thing to send. The comparison is a hash over the page that was
|
|
184
|
+
read, taken at the same item limit, so the re-read has to match message for
|
|
185
|
+
message. A chat whose most recent read predates this guard carries no such hash
|
|
186
|
+
and is refused with `428 read_before_send_required` until it is read again,
|
|
187
|
+
rather than waved through on a timestamp that cannot answer the question. If the
|
|
188
|
+
re-read itself fails, the send is refused with
|
|
189
|
+
`502 thread_preflight_unavailable`: whether the thread changed is then unknown,
|
|
190
|
+
and unknown is not permission. All three refusals happen before anything is
|
|
191
|
+
reserved, so none of them costs send budget.
|
|
192
|
+
|
|
193
|
+
A provider answer this process cannot read counts as a re-read that failed, not
|
|
194
|
+
as an empty conversation. Only the two documented list envelopes are hashed;
|
|
195
|
+
anything else yields no hash at all, so a read that hits a drifted shape records
|
|
196
|
+
no authorization (the next send is refused at `428`) and a re-read that hits one
|
|
197
|
+
refuses at `502` rather than being compared. That distinction is the whole
|
|
198
|
+
guard: hashing an unrecognised answer to the hash of an empty thread would let a
|
|
199
|
+
read and a re-read that had both seen nothing agree with each other and satisfy
|
|
200
|
+
the requirement that a human looked at the conversation.
|
|
201
|
+
|
|
174
202
|
A send is not idempotent, so an unconfirmed one is not silently retryable. If
|
|
175
203
|
the provider times out, fails with a 5xx, or answers 2xx with a body that will
|
|
176
204
|
not parse, the message may well have been delivered: SignalDash records the
|
|
@@ -205,6 +233,32 @@ uses the same sender binding, action ledger, duplicate guard, daily budget, and
|
|
|
205
233
|
provider-warning lock as immediate publishing. An interrupted or ambiguous
|
|
206
234
|
execution fails closed and is never retried automatically.
|
|
207
235
|
|
|
236
|
+
`sd_schedule_message` puts one exact message into one exact chat at one exact
|
|
237
|
+
time, on WhatsApp or LinkedIn, text only. It enqueues; it never sends. The
|
|
238
|
+
attachment bytes a WhatsApp send accepts are refused here rather than held on
|
|
239
|
+
disk for days. The worker sends,
|
|
240
|
+
through `sendWhatsAppMessage` and through the LinkedIn preflight and write path,
|
|
241
|
+
so a scheduled message clears every guard a live send clears, including the
|
|
242
|
+
daily budget and the duplicate guard, at the moment it fires rather than at the
|
|
243
|
+
moment it was written. Scheduling is refused with
|
|
244
|
+
`428 read_before_send_required` unless this session has already read that exact
|
|
245
|
+
chat, at `limit` 10 or more on LinkedIn, because the row has to carry a hash of
|
|
246
|
+
the conversation it was written against. That hash is read from the recorded
|
|
247
|
+
read and cannot be supplied by the caller: `watermark` and `item_limit` are
|
|
248
|
+
refused by name rather than accepted and overwritten, since a caller that
|
|
249
|
+
believes it set them believes something false. At fire time the worker re-reads
|
|
250
|
+
the thread and compares. Anything other than "unchanged" parks the row in
|
|
251
|
+
`needs_review` with a reason and sends nothing, because a conversation that
|
|
252
|
+
moved is a question for a human and not a failure of the message.
|
|
253
|
+
`sd_scheduled_messages` lists the caller's own rows with those reasons, and
|
|
254
|
+
counts the `needs_review` ones outside whatever filter was asked for, so a
|
|
255
|
+
filtered listing cannot hide a message waiting on a person.
|
|
256
|
+
`sd_cancel_scheduled_message` cancels a row that has not fired.
|
|
257
|
+
A row belonging to another account is refused in exactly the same words as an id
|
|
258
|
+
that never existed, so the refusal cannot be used to prove somebody else's
|
|
259
|
+
message is real. A row that already sent is refused with the plain statement
|
|
260
|
+
that cancelling cannot unsend it.
|
|
261
|
+
|
|
208
262
|
A campaign is one connection request per approved person, then the approved
|
|
209
263
|
message once a fresh profile read proves that person accepted, then an optional
|
|
210
264
|
follow-up that stops the moment they reply. A pending invitation that merely
|
|
@@ -356,6 +410,50 @@ resume exactly those. WhatsApp applies its own time and role limits to deleting
|
|
|
356
410
|
for everyone and can answer successfully without removing anything, so re-read
|
|
357
411
|
the chat to confirm.
|
|
358
412
|
|
|
413
|
+
## Releasing
|
|
414
|
+
|
|
415
|
+
A release is a tag push. `.github/workflows/release.yml` runs `./release.sh` on
|
|
416
|
+
the AX41 self-hosted runner, and that script is the same one a laptop runs, so
|
|
417
|
+
there is no second code path to keep honest:
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
git tag v0.28.0 && git push origin v0.28.0
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`release.sh` refuses to publish if the packed files have uncommitted changes or
|
|
424
|
+
if the version in `package.json` is already on the registry, then runs the
|
|
425
|
+
syntax check, the tests, `npm pack`, and the prepublish smoke gate against the
|
|
426
|
+
tarball it just built. That gate installs the tarball into a scratch HOME and
|
|
427
|
+
drives the real CLI, so a package that unpacks but cannot run never ships. Any
|
|
428
|
+
other branch or a `workflow_dispatch` run does all of that and stops one command
|
|
429
|
+
short, at `./release.sh --dry-run`.
|
|
430
|
+
|
|
431
|
+
**The credential is the `NPM_TOKEN` secret**, an npm granular access token
|
|
432
|
+
scoped to `@floomhq/signaldash` with write access:
|
|
433
|
+
|
|
434
|
+
```bash
|
|
435
|
+
gh secret set NPM_TOKEN -R floomhq/signaldash
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
Trusted publishing (OIDC) is not an option here, so do not spend time on it:
|
|
439
|
+
npm does not accept self-hosted runners yet, and this repo is on a self-hosted
|
|
440
|
+
runner because GitHub-hosted minutes are unavailable. The workflow asks the
|
|
441
|
+
registry directly on every run (`scripts/oidc-check.mjs`) and prints the answer,
|
|
442
|
+
because npm's own OIDC helper is written never to throw and turns a refusal into
|
|
443
|
+
an ordinary auth error.
|
|
444
|
+
|
|
445
|
+
To exercise the publish itself without touching npmjs, run it against a
|
|
446
|
+
throwaway registry:
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
scripts/publish-rehearsal.sh
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
That starts verdaccio in a container, mints an account on it, and runs the real
|
|
453
|
+
`./release.sh` with no `--dry-run`, then prints what the registry received. It
|
|
454
|
+
is the only way to test the publish command and the tarball a client actually
|
|
455
|
+
downloads before a version number is spent.
|
|
456
|
+
|
|
359
457
|
## Development
|
|
360
458
|
|
|
361
459
|
```bash
|
package/bin/sd.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import { createInterface } from "node:readline";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { readConfigFile, updateConfigFile } from "../lib/config-file.js";
|
|
14
|
+
import { renderSkillTemplate } from "../lib/skill-template.cjs";
|
|
14
15
|
|
|
15
16
|
// Lazy-load presentation deps so `mcp` (stdio, machine-facing) stays clean/fast.
|
|
16
17
|
async function ui() {
|
|
@@ -20,6 +21,25 @@ async function ui() {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const DEFAULT_BACKEND = process.env.SIGNALDASH_BACKEND || "https://signaldash-api.floom.dev";
|
|
24
|
+
// The shape of an invite code, deliberately a hand-kept copy of
|
|
25
|
+
// INVITE_CODE_SHAPE in server/server.cjs rather than an import: server/ is
|
|
26
|
+
// not in package.json's "files" allowlist, so it never ships in the
|
|
27
|
+
// published npm tarball and bin/sd.mjs cannot depend on it at runtime. Two
|
|
28
|
+
// copies of this shape can therefore never be textually merged into one;
|
|
29
|
+
// test/invite-code-shortening.test.js requires server.cjs (test-only, not
|
|
30
|
+
// shipped) to assert the CLI still accepts every code the live server
|
|
31
|
+
// actually issues, which is the guarantee that matters here.
|
|
32
|
+
//
|
|
33
|
+
// The floor here (8 hex chars) is intentionally HIGHER than the server's
|
|
34
|
+
// own floor (6): the server keeps 6 low for backward compatibility with
|
|
35
|
+
// already-issued short codes it must still honor, but 6 hex chars is only
|
|
36
|
+
// 24 bits of entropy for a value an attacker can brute-force against the
|
|
37
|
+
// login endpoint, so the CLI does not need to (and should not) accept
|
|
38
|
+
// anything shorter than the 32-bit floor this project has always minted
|
|
39
|
+
// against. Every code production has ever issued is 10+ hex chars, or the
|
|
40
|
+
// sd-XXXX-XXXX form below; the shape census is in
|
|
41
|
+
// test/invite-code-shortening.test.js.
|
|
42
|
+
export const INVITE_CODE_REGEX = /^(?:[0-9a-f]{8,32}|sd-[0-9a-f]{4}-[0-9a-f]{4})$/i;
|
|
23
43
|
const PACKAGE_VERSION = JSON.parse(
|
|
24
44
|
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
25
45
|
).version;
|
|
@@ -87,13 +107,18 @@ export async function cmdLogin(code, backend, dependencies = {}) {
|
|
|
87
107
|
{ auth: false, backend: targetBackend },
|
|
88
108
|
);
|
|
89
109
|
if (r.status !== 200) {
|
|
110
|
+
// Previously any error whose text loosely matched /used|invalid|unknown/i
|
|
111
|
+
// was swallowed into "you are already set up" whenever a local token
|
|
112
|
+
// existed. That was never precise: the server's /login route treats a
|
|
113
|
+
// revoked code exactly like an unknown one on purpose (same 403, see
|
|
114
|
+
// server.cjs, so an attacker can't tell them apart), and it does NOT
|
|
115
|
+
// reject a code that was already redeemed -- re-running `login` with the
|
|
116
|
+
// SAME valid code succeeds with a fresh 200 token, it never reaches this
|
|
117
|
+
// branch. So a 403/other error here, even with a local token present,
|
|
118
|
+
// means the code just typed is wrong, expired, or revoked -- never "you
|
|
119
|
+
// already did this". Report it honestly instead of masking it as success.
|
|
90
120
|
const msg = String(r.json.error || r.status);
|
|
91
|
-
if (/used|invalid/i.test(msg) && loadCfg().token) {
|
|
92
|
-
log("You are already set up on this machine. Run `signaldash status` to see what is connected.");
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
121
|
error("login failed:", msg);
|
|
96
|
-
if (/used/i.test(msg)) error("Invite codes are single-use. If you already ran this, try: signaldash status");
|
|
97
122
|
process.exitCode = 1; return;
|
|
98
123
|
}
|
|
99
124
|
updateCfg(current => ({
|
|
@@ -106,6 +131,46 @@ export async function cmdLogin(code, backend, dependencies = {}) {
|
|
|
106
131
|
log("machine, and are never exposed to your agent. Only you can see your data.");
|
|
107
132
|
}
|
|
108
133
|
|
|
134
|
+
// Store a session token you already hold. The /i/<code> page used to print
|
|
135
|
+
// one as the final step of web onboarding and this command did not exist, so
|
|
136
|
+
// people hit "unknown command" at the one point where they wire up their
|
|
137
|
+
// agent. That page no longer hands out tokens at all (reopening it would have
|
|
138
|
+
// traded a leaked invite code for someone else's live session), so the normal
|
|
139
|
+
// way in is `login <code>`. This stays for a token handed over out of band.
|
|
140
|
+
export async function cmdLoginToken(token, backend, dependencies = {}) {
|
|
141
|
+
const request = dependencies.request || api;
|
|
142
|
+
const log = dependencies.log || console.log;
|
|
143
|
+
const error = dependencies.error || console.error;
|
|
144
|
+
if (!token) {
|
|
145
|
+
error("usage: signaldash login-token <token>");
|
|
146
|
+
process.exitCode = 1; return;
|
|
147
|
+
}
|
|
148
|
+
const targetBackend = backend || loadCfg().backend || DEFAULT_BACKEND;
|
|
149
|
+
// Verify before persisting. A mistyped or expired token written to disk would
|
|
150
|
+
// fail later as a confusing "not connected" on every command instead of here.
|
|
151
|
+
const probe = await request(
|
|
152
|
+
"/connect/whatsapp/status",
|
|
153
|
+
undefined,
|
|
154
|
+
{ method: "GET", backend: targetBackend, token },
|
|
155
|
+
);
|
|
156
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
157
|
+
error("login failed: that token is not valid. It may have expired, or a newer login replaced it.");
|
|
158
|
+
process.exitCode = 1; return;
|
|
159
|
+
}
|
|
160
|
+
if (probe.status >= 400) {
|
|
161
|
+
error("login failed:", probe.json.error || probe.status);
|
|
162
|
+
process.exitCode = 1; return;
|
|
163
|
+
}
|
|
164
|
+
updateCfg(current => ({
|
|
165
|
+
...current,
|
|
166
|
+
backend: targetBackend,
|
|
167
|
+
token,
|
|
168
|
+
}));
|
|
169
|
+
log(`Logged in to SignalDash (${targetBackend}).`);
|
|
170
|
+
log("Your LinkedIn/WhatsApp/email credentials live on that server, not on this");
|
|
171
|
+
log("machine, and are never exposed to your agent. Only you can see your data.");
|
|
172
|
+
}
|
|
173
|
+
|
|
109
174
|
export async function cmdLogout(dependencies = {}) {
|
|
110
175
|
const request = dependencies.request || api;
|
|
111
176
|
const log = dependencies.log || console.log;
|
|
@@ -947,7 +1012,7 @@ const TOOLS = [
|
|
|
947
1012
|
name: "wa_send_message",
|
|
948
1013
|
ch: "wa",
|
|
949
1014
|
action: "send",
|
|
950
|
-
description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments. Read the chat first: a send into a thread this account has not read recently is refused. `attachments` optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. `text` is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with `409 send_outcome_unknown`. Read the chat, and only if the message is genuinely absent, resend the identical payload with `confirm_resend:true`.",
|
|
1015
|
+
description: "Send a WhatsApp message (rate-safe). args: chat_id, text, attachments. Read the chat first: a send into a thread this account has not read recently is refused. The send re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after that read, because a draft written against the old thread may now be deaf or wrong; re-read, revise, and send again. A re-read that fails is `502 thread_preflight_unavailable` and nothing was sent. `attachments` optionally carries up to 4 base64 files as exact {filename, content_type, content_base64} objects, at most 16 MiB per file and 16 MiB per message, and accepts images, PDF, CSV, plain text, JSON, xlsx and zip. `text` is the caption and may be omitted when a file is attached, but a call carrying neither text nor an attachment is refused. An attachment send is rate-limited, deduplicated and recorded exactly like a text send, and spends the same daily budget. If a send times out or the provider never confirms it, the message may still have been delivered: SignalDash records it and refuses an identical retry with `409 send_outcome_unknown`. Read the chat, and only if the message is genuinely absent, resend the identical payload with `confirm_resend:true`.",
|
|
951
1016
|
inputSchema: {
|
|
952
1017
|
type: "object",
|
|
953
1018
|
properties: {
|
|
@@ -982,11 +1047,20 @@ const TOOLS = [
|
|
|
982
1047
|
confirm_resend: { type: "boolean", const: true },
|
|
983
1048
|
},
|
|
984
1049
|
// `text` alone is no longer required: a document with no caption is a
|
|
985
|
-
// legitimate message.
|
|
986
|
-
//
|
|
987
|
-
//
|
|
1050
|
+
// legitimate message.
|
|
1051
|
+
//
|
|
1052
|
+
// Die Regel stand hier bis zum 11.08. als `anyOf` auf oberster Ebene.
|
|
1053
|
+
// Das hat das Werkzeug bei einem Client gekostet, der Schemata lokal
|
|
1054
|
+
// kompiliert: er hat es kommentarlos aus seiner Liste geworfen. Ueber
|
|
1055
|
+
// Tage war wa_send_message das EINZIGE der 45 Werkzeuge, das dort fehlte,
|
|
1056
|
+
// und zugleich das einzige mit einem top-level anyOf. Der Server hat es
|
|
1057
|
+
// die ganze Zeit ausgeliefert.
|
|
1058
|
+
//
|
|
1059
|
+
// Die Regel geht dadurch nicht verloren: das Backend weist einen Aufruf
|
|
1060
|
+
// ohne Text und ohne Anhang ohnehin ab, unabhaengig davon, ob ein Client
|
|
1061
|
+
// vorher geprueft hat. Sie steht in der Beschreibung, wo jeder Client sie
|
|
1062
|
+
// lesen kann, statt in einem Konstrukt, an dem einer von ihnen erstickt.
|
|
988
1063
|
required: ["chat_id"],
|
|
989
|
-
anyOf: [{ required: ["text"] }, { required: ["attachments"] }],
|
|
990
1064
|
additionalProperties: false,
|
|
991
1065
|
},
|
|
992
1066
|
},
|
|
@@ -1033,10 +1107,18 @@ const TOOLS = [
|
|
|
1033
1107
|
{
|
|
1034
1108
|
name: "email_list",
|
|
1035
1109
|
path: "/email/list",
|
|
1036
|
-
description: "List the newest message from each recent email thread. args: limit",
|
|
1110
|
+
description: "List the newest message from each recent email thread. args: limit, cursor",
|
|
1037
1111
|
inputSchema: {
|
|
1038
1112
|
type: "object",
|
|
1039
|
-
properties: {
|
|
1113
|
+
properties: {
|
|
1114
|
+
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
1115
|
+
cursor: {
|
|
1116
|
+
type: "string",
|
|
1117
|
+
minLength: 1,
|
|
1118
|
+
maxLength: 4096,
|
|
1119
|
+
description: "Fetch the next page. Use the cursor returned by a previous email_list.",
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1040
1122
|
additionalProperties: false,
|
|
1041
1123
|
},
|
|
1042
1124
|
},
|
|
@@ -1057,7 +1139,7 @@ const TOOLS = [
|
|
|
1057
1139
|
{
|
|
1058
1140
|
name: "email_send",
|
|
1059
1141
|
path: "/email/send",
|
|
1060
|
-
description: "Send one approved email after reading that recipient's thread. args: to, subject, body",
|
|
1142
|
+
description: "Send one approved email after reading that recipient's thread. Pass thread_id from email_read to reply inside that thread rather than starting a new one. args: to, subject, body, thread_id",
|
|
1061
1143
|
inputSchema: {
|
|
1062
1144
|
type: "object",
|
|
1063
1145
|
properties: {
|
|
@@ -1069,6 +1151,16 @@ const TOOLS = [
|
|
|
1069
1151
|
},
|
|
1070
1152
|
subject: { type: "string", minLength: 1, maxLength: 998 },
|
|
1071
1153
|
body: { type: "string", minLength: 1, maxLength: 5000 },
|
|
1154
|
+
thread_id: {
|
|
1155
|
+
type: "string",
|
|
1156
|
+
minLength: 1,
|
|
1157
|
+
maxLength: 500,
|
|
1158
|
+
description: "Reply inside this thread. Use the thread_id returned by email_read.",
|
|
1159
|
+
},
|
|
1160
|
+
confirm_resend: {
|
|
1161
|
+
type: "boolean",
|
|
1162
|
+
description: "Only after a send_outcome_unknown refusal, and only once you have read the thread again and confirmed the email is genuinely absent.",
|
|
1163
|
+
},
|
|
1072
1164
|
},
|
|
1073
1165
|
required: ["to", "subject", "body"],
|
|
1074
1166
|
additionalProperties: false,
|
|
@@ -1189,6 +1281,65 @@ const TOOLS = [
|
|
|
1189
1281
|
additionalProperties: false,
|
|
1190
1282
|
},
|
|
1191
1283
|
},
|
|
1284
|
+
// sd_, not wa_ or li_, because these three carry `channel` and act on either
|
|
1285
|
+
// one. The prefixes here are a claim about scope: wa_ and li_ tools reach
|
|
1286
|
+
// exactly one network and their arguments say so, and every tool that spans
|
|
1287
|
+
// both or belongs to SignalDash itself is sd_ already, from sd_contact_state
|
|
1288
|
+
// through the sd_campaign_ and sd_withdrawal_batch_ families.
|
|
1289
|
+
{
|
|
1290
|
+
name: "sd_schedule_message",
|
|
1291
|
+
path: "/sd/schedule_message",
|
|
1292
|
+
description: "Schedule ONE exact message into ONE chat you have already read, on WhatsApp or LinkedIn. Read that exact chat first, at limit 10 or more on LinkedIn: SignalDash records what the thread looked like and refuses to send if the conversation moved before the scheduled time. Requires confirm:true after the human approves the exact channel, chat, text, and time. Text only, no attachments. This is not a follow-up sequence and there is no recurrence: one message, one time.",
|
|
1293
|
+
inputSchema: {
|
|
1294
|
+
type: "object",
|
|
1295
|
+
properties: {
|
|
1296
|
+
channel: { type: "string", enum: ["whatsapp", "linkedin"] },
|
|
1297
|
+
chat_id: { type: "string", minLength: 1, maxLength: 500 },
|
|
1298
|
+
text: { type: "string", minLength: 1, maxLength: 5000 },
|
|
1299
|
+
scheduled_at: { type: "string", format: "date-time" },
|
|
1300
|
+
confirm: { type: "boolean", const: true },
|
|
1301
|
+
},
|
|
1302
|
+
required: ["channel", "chat_id", "text", "scheduled_at", "confirm"],
|
|
1303
|
+
additionalProperties: false,
|
|
1304
|
+
},
|
|
1305
|
+
},
|
|
1306
|
+
{
|
|
1307
|
+
name: "sd_scheduled_messages",
|
|
1308
|
+
path: "/sd/scheduled_messages",
|
|
1309
|
+
description: "List this authenticated user's scheduled messages and their durable states, optionally filtered by state or channel. Always reports needs_review_count outside that filter: a message in needs_review stopped at send time and is waiting on a human.",
|
|
1310
|
+
inputSchema: {
|
|
1311
|
+
type: "object",
|
|
1312
|
+
properties: {
|
|
1313
|
+
state: {
|
|
1314
|
+
type: "string",
|
|
1315
|
+
enum: [
|
|
1316
|
+
"scheduled",
|
|
1317
|
+
"executing",
|
|
1318
|
+
"sent",
|
|
1319
|
+
"cancelled",
|
|
1320
|
+
"failed",
|
|
1321
|
+
"needs_review",
|
|
1322
|
+
],
|
|
1323
|
+
},
|
|
1324
|
+
channel: { type: "string", enum: ["whatsapp", "linkedin"] },
|
|
1325
|
+
},
|
|
1326
|
+
additionalProperties: false,
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
name: "sd_cancel_scheduled_message",
|
|
1331
|
+
path: "/sd/cancel_scheduled_message",
|
|
1332
|
+
description: "Cancel one exact scheduled message while it is still scheduled. Requires id and confirm:true. It cannot stop a message already being sent, and it cannot unsend one that has been sent.",
|
|
1333
|
+
inputSchema: {
|
|
1334
|
+
type: "object",
|
|
1335
|
+
properties: {
|
|
1336
|
+
id: { type: "string", format: "uuid" },
|
|
1337
|
+
confirm: { type: "boolean", const: true },
|
|
1338
|
+
},
|
|
1339
|
+
required: ["id", "confirm"],
|
|
1340
|
+
additionalProperties: false,
|
|
1341
|
+
},
|
|
1342
|
+
},
|
|
1192
1343
|
];
|
|
1193
1344
|
// No catch-all fallback. It advertised one union of keys for every tool that
|
|
1194
1345
|
// had no schema of its own -- which is how `li_list_chats` came to offer `text`
|
|
@@ -1242,7 +1393,14 @@ export async function cmdSkill(dependencies = {}) {
|
|
|
1242
1393
|
if (!ex(src)) { log("skill file not found in package"); process.exitCode = 1; return; }
|
|
1243
1394
|
const dest = join(homedir(), ".claude", "skills", "signaldash");
|
|
1244
1395
|
mk(dest, { recursive: true });
|
|
1245
|
-
|
|
1396
|
+
// The template pins the bootstrap command to a version placeholder rather
|
|
1397
|
+
// than a typed-in string: package.json is the only source of truth, so an
|
|
1398
|
+
// installed skill can never advertise a stale published release. Rendered
|
|
1399
|
+
// by the shared helper (lib/skill-template.cjs) so this substitution has a
|
|
1400
|
+
// single source of truth shared with the public skill page and the
|
|
1401
|
+
// server's own /skill and /skill.md routes.
|
|
1402
|
+
const template = rf(src, "utf8");
|
|
1403
|
+
wf(join(dest, "SKILL.md"), renderSkillTemplate(template, PACKAGE_VERSION));
|
|
1246
1404
|
log(`Installed the SignalDash skill to ${dest}/SKILL.md`);
|
|
1247
1405
|
log("Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.");
|
|
1248
1406
|
}
|
|
@@ -1266,7 +1424,7 @@ export async function cmdSetup(code, dependencies = {}) {
|
|
|
1266
1424
|
log(" " + chalk.green("+") + " agent skill installed");
|
|
1267
1425
|
|
|
1268
1426
|
try {
|
|
1269
|
-
execSync("claude mcp add signaldash -- npx -y @floomhq/signaldash mcp", { stdio: "ignore" });
|
|
1427
|
+
execSync("claude mcp add signaldash -s user -- npx -y @floomhq/signaldash mcp", { stdio: "ignore" });
|
|
1270
1428
|
log(" " + chalk.green("+") + " MCP registered with Claude Code");
|
|
1271
1429
|
} catch {
|
|
1272
1430
|
log(" " + chalk.yellow("!") + " Claude Code not found. For Cursor, add to .cursor/mcp.json:");
|
|
@@ -1365,6 +1523,7 @@ function printHelp(log = console.log) {
|
|
|
1365
1523
|
log(`SignalDash \u2014 secure LinkedIn, WhatsApp and email access for your AI agent.
|
|
1366
1524
|
|
|
1367
1525
|
signaldash <invite-code> set up everything in one go
|
|
1526
|
+
signaldash login-token <token> store a session token you were given
|
|
1368
1527
|
signaldash status show what is connected
|
|
1369
1528
|
signaldash connect linkedin|whatsapp|email
|
|
1370
1529
|
signaldash connections [file.csv] export your LinkedIn connections
|
|
@@ -1380,8 +1539,9 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
1380
1539
|
const [cmd, a, b, c] = argv;
|
|
1381
1540
|
const log = dependencies.log || console.log;
|
|
1382
1541
|
if (cmd === "setup") await cmdSetup(a, dependencies);
|
|
1383
|
-
else if (cmd &&
|
|
1542
|
+
else if (cmd && INVITE_CODE_REGEX.test(cmd) && !["login","logout","connect","mcp","skill"].includes(cmd)) await cmdSetup(cmd, dependencies);
|
|
1384
1543
|
else if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined, dependencies);
|
|
1544
|
+
else if (cmd === "login-token") await cmdLoginToken(a, b === "--backend" ? c : undefined, dependencies);
|
|
1385
1545
|
else if (cmd === "logout") await cmdLogout(dependencies);
|
|
1386
1546
|
else if (cmd === "connect" && b === "claim") await cmdClaim(a, c, dependencies);
|
|
1387
1547
|
else if (cmd === "connect") await cmdConnect(a, dependencies);
|
|
@@ -1390,8 +1550,7 @@ export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
|
1390
1550
|
else if (cmd === "connections" || (cmd === "export" && a === "connections")) await cmdConnections(cmd === "export" ? b : a, dependencies);
|
|
1391
1551
|
else if (cmd === "--version" || cmd === "-v") log(PACKAGE_VERSION);
|
|
1392
1552
|
else if (cmd === "skill") await cmdSkill(dependencies);
|
|
1393
|
-
else if (cmd
|
|
1394
|
-
else if (cmd && !["help","--help","-h"].includes(cmd) && !/^[0-9a-f]{8,}$/i.test(cmd)) { (dependencies.error || console.error)(`unknown command: ${cmd}`); printHelp(log); process.exitCode = 1; }
|
|
1553
|
+
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; }
|
|
1395
1554
|
else printHelp(log);
|
|
1396
1555
|
}
|
|
1397
1556
|
|
package/lib/cli.js
CHANGED
|
@@ -290,7 +290,7 @@ export async function connectCommand(channel, options, dependencies = {}) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
function registrationLine() {
|
|
293
|
-
return "Register: claude mcp add signaldash -- npx -y @floomhq/signaldash mcp | Cursor command: npx -y @floomhq/signaldash mcp";
|
|
293
|
+
return "Register: claude mcp add signaldash -s user -- npx -y @floomhq/signaldash mcp | Cursor command: npx -y @floomhq/signaldash mcp";
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
export async function mcpCommand(options, dependencies = {}) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Single source of truth for substituting the skill template's version
|
|
4
|
+
// placeholder. Three call sites independently need this exact substitution:
|
|
5
|
+
// `cmdSkill` in bin/sd.mjs (installs the skill into ~/.claude/skills), the
|
|
6
|
+
// public signaldash.dev page renderer (ops/render-skill-page.mjs and
|
|
7
|
+
// ops/render-skill-markdown.mjs), and the server's own /skill + /skill.md
|
|
8
|
+
// routes (server/server.cjs). Before this file existed the substitution was
|
|
9
|
+
// hand-duplicated in bin/sd.mjs and ops/render-skill-page.mjs, and the
|
|
10
|
+
// server routes and ops/sync-public-skill.sh did not substitute at all --
|
|
11
|
+
// which is how the publicly served skill ended up telling agents to run
|
|
12
|
+
// `npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>`, a command
|
|
13
|
+
// that does not exist.
|
|
14
|
+
//
|
|
15
|
+
// .cjs so both ESM (bin/sd.mjs, ops/*.mjs, via Node's CJS/ESM interop) and
|
|
16
|
+
// CommonJS (server/server.cjs) call sites can require/import it without a
|
|
17
|
+
// build step or a second copy.
|
|
18
|
+
|
|
19
|
+
const PACKAGE_VERSION_PLACEHOLDER = '{{PACKAGE_VERSION}}';
|
|
20
|
+
|
|
21
|
+
function renderSkillTemplate(rawMarkdown, packageVersion) {
|
|
22
|
+
if (typeof rawMarkdown !== 'string') {
|
|
23
|
+
throw new TypeError('renderSkillTemplate: rawMarkdown must be a string');
|
|
24
|
+
}
|
|
25
|
+
if (typeof packageVersion !== 'string' || !packageVersion) {
|
|
26
|
+
throw new TypeError('renderSkillTemplate: packageVersion must be a non-empty string');
|
|
27
|
+
}
|
|
28
|
+
return rawMarkdown.split(PACKAGE_VERSION_PLACEHOLDER).join(packageVersion);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
exports.renderSkillTemplate = renderSkillTemplate;
|
|
32
|
+
exports.PACKAGE_VERSION_PLACEHOLDER = PACKAGE_VERSION_PLACEHOLDER;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floomhq/signaldash",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
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/attachments.cjs && node --check server/write-control.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 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/composio-email.cjs && node --check server/attachments.cjs && node --check server/write-control.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 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",
|
|
@@ -39,18 +39,20 @@ ask the human to review and run the pinned package bootstrap in their terminal.
|
|
|
39
39
|
1. The human runs the currently published, pinned package:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
npx -y @floomhq/signaldash@
|
|
42
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
This logs in, copies the package-bundled skill into Claude Code, registers
|
|
46
46
|
the local MCP server, and starts the hosted account-connection flow.
|
|
47
47
|
|
|
48
|
-
2. The
|
|
49
|
-
|
|
48
|
+
2. The package writes the installed copy to the one location it supports
|
|
49
|
+
today:
|
|
50
50
|
|
|
51
51
|
- Claude Code: `~/.claude/skills/signaldash/SKILL.md`
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
|
|
53
|
+
Other clients (Codex, a portable `.agents/skills/` layout, etc.) are not
|
|
54
|
+
written automatically. Copy this same reviewed file into that client's own
|
|
55
|
+
skill directory by hand and reload skill discovery there.
|
|
54
56
|
|
|
55
57
|
3. Confirm that the installed file begins with `name: signaldash` and contains the
|
|
56
58
|
`Before every send` section. A partial copy is not installed.
|
|
@@ -63,7 +65,7 @@ ask the human to review and run the pinned package bootstrap in their terminal.
|
|
|
63
65
|
account setup:
|
|
64
66
|
|
|
65
67
|
```bash
|
|
66
|
-
npx -y @floomhq/signaldash@
|
|
68
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} skill
|
|
67
69
|
```
|
|
68
70
|
|
|
69
71
|
Expected success:
|
|
@@ -159,7 +161,7 @@ one, or reuse somebody else's code.
|
|
|
159
161
|
After the user provides the code, run:
|
|
160
162
|
|
|
161
163
|
```bash
|
|
162
|
-
npx -y @floomhq/signaldash <invite-code>
|
|
164
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
|
|
163
165
|
```
|
|
164
166
|
|
|
165
167
|
The invite is a hexadecimal string. The command logs in, installs the bundled
|
|
@@ -374,12 +376,12 @@ Use the exact tool names and argument keys below. Limits are optional.
|
|
|
374
376
|
| `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. |
|
|
375
377
|
| `wa_get_attachment` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each | Download one attachment of one message in a chat this account owns. Read the chat first: the exact `message_id` and `attachment_id` come from `wa_read_messages`. Returns the stored path on the SignalDash host, mimetype, byte size and sha256. |
|
|
376
378
|
| `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. |
|
|
377
|
-
| `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 | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. 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. LinkedIn messages carry text only. |
|
|
379
|
+
| `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 | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. 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. The send also re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after the read that authorized it, or with `502 thread_preflight_unavailable` if that re-read fails; in both cases nothing was sent and no budget was spent. A chat whose most recent read predates thread-change detection is refused with `428 read_before_send_required` until it is read again. LinkedIn messages carry text only. |
|
|
378
380
|
| `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so read the chat again to confirm the message is gone. |
|
|
379
381
|
| `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. |
|
|
380
|
-
| `email_list` | `limit` integer 1-100, default 20 | List the newest message in each recent email thread and obtain `thread_id`. |
|
|
381
|
-
| `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. |
|
|
382
|
-
| `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998; `body` required, max 5000 | Send one approved email to a participant in a recently read existing thread. |
|
|
382
|
+
| `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. |
|
|
383
|
+
| `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. |
|
|
384
|
+
| `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`. |
|
|
383
385
|
| `li_my_posts` | `limit` default 10, max 50; `member_id` optional | Find the user's latest posts and post IDs. Omit `member_id` to use the connected user's own ID. |
|
|
384
386
|
| `li_post_reactions` | `post_id` required; `limit` default 50, max 100 | Identify who reacted to one post and assess warm signals. A reaction does not authorize outreach. |
|
|
385
387
|
| `li_post_comments` | `post_id` required; `limit` default 50, max 100 | Read comments and authors for one post; prioritize questions and substantive responses. |
|
|
@@ -387,6 +389,9 @@ Use the exact tool names and argument keys below. Limits are optional.
|
|
|
387
389
|
| `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. |
|
|
388
390
|
| `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
|
|
389
391
|
| `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. |
|
|
392
|
+
| `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. |
|
|
393
|
+
| `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. |
|
|
394
|
+
| `sd_cancel_scheduled_message` | `id` required UUID; `confirm:true` required | Cancel one exact message while its state is still `scheduled`. It cannot stop one already being sent, and it cannot unsend one that has been sent. |
|
|
390
395
|
|
|
391
396
|
Representative calls:
|
|
392
397
|
|
|
@@ -430,7 +435,7 @@ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
|
|
|
430
435
|
wa_delete_messages({"messages":[{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"},{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c72"}]})
|
|
431
436
|
email_list({"limit":20})
|
|
432
437
|
email_read({"thread_id":"thread_email_c402","limit":30})
|
|
433
|
-
email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study."})
|
|
438
|
+
email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study.","thread_id":"thread_email_c402"})
|
|
434
439
|
li_my_posts({"limit":5})
|
|
435
440
|
li_post_reactions({"post_id":"post_urn_8821","limit":50})
|
|
436
441
|
li_post_comments({"post_id":"post_urn_8821","limit":50})
|
|
@@ -438,6 +443,10 @@ li_draft_post({"text":"Most agents need better context, not more autonomy."})
|
|
|
438
443
|
li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
|
|
439
444
|
li_scheduled_posts({})
|
|
440
445
|
li_cancel_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
|
|
446
|
+
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})
|
|
447
|
+
sd_scheduled_messages({})
|
|
448
|
+
sd_scheduled_messages({"state":"needs_review"})
|
|
449
|
+
sd_cancel_scheduled_message({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
|
|
441
450
|
```
|
|
442
451
|
|
|
443
452
|
List and read success returns JSON with `items` and often a `cursor`. A message
|
|
@@ -565,6 +574,52 @@ this week, failed attempts today, remaining dedicated and shared capacity,
|
|
|
565
574
|
`consecutive_errors`, `disabled_reason`, and every recent unparseable record.
|
|
566
575
|
Never describe a disabled or erroring worker as active.
|
|
567
576
|
|
|
577
|
+
### Scheduling one exact message
|
|
578
|
+
|
|
579
|
+
`sd_schedule_message` holds one exact message for one exact future instant and
|
|
580
|
+
sends it once. It is not a follow-up, a sequence, or a reminder that repeats.
|
|
581
|
+
Schedule a message only when the user asked for that message at that time.
|
|
582
|
+
|
|
583
|
+
A scheduled send is the only write in SignalDash that reaches a person with
|
|
584
|
+
nobody watching, so it is guarded twice: once when you schedule it and once
|
|
585
|
+
again at the moment it fires.
|
|
586
|
+
|
|
587
|
+
At schedule time you must have read the exact chat, on LinkedIn at `limit` 10 or
|
|
588
|
+
more. SignalDash records what that read returned. A message you cannot prove was
|
|
589
|
+
written against a real, recent view of the conversation is refused now rather
|
|
590
|
+
than accepted and quietly parked days later, while there is still a human in the
|
|
591
|
+
room to hear about it. This is why `watermark` and `item_limit` are not
|
|
592
|
+
arguments: they are read off the record of your own read, and a proof a caller
|
|
593
|
+
could supply is not a proof.
|
|
594
|
+
|
|
595
|
+
At fire time SignalDash re-reads the thread and compares it. If the conversation
|
|
596
|
+
moved, the message is not sent. It goes to `needs_review` for a human instead,
|
|
597
|
+
because a message written on Monday against Monday's conversation may be deaf or
|
|
598
|
+
wrong by Thursday, and the person receiving it has no way to know it was written
|
|
599
|
+
before their reply existed. Everything else the live send path checks still
|
|
600
|
+
applies at that moment: chat ownership, contact suppression, the duplicate
|
|
601
|
+
guard, and the daily send budget.
|
|
602
|
+
|
|
603
|
+
`confirm:true` is required, and it means the human approved this exact channel,
|
|
604
|
+
chat, text, and time. `scheduled_at` must carry `Z` or an explicit numeric UTC
|
|
605
|
+
offset. SignalDash does not infer a timezone: it does not know which one you
|
|
606
|
+
meant, and the cost of guessing wrong is a message arriving in the middle of
|
|
607
|
+
somebody's night.
|
|
608
|
+
|
|
609
|
+
Check `sd_scheduled_messages` after scheduling, and read `needs_review_count`
|
|
610
|
+
every time even when you filtered it out. A row in `needs_review` stopped at
|
|
611
|
+
send time and is waiting on a person; its `failure_detail` says what stopped it.
|
|
612
|
+
For some of those rows whether the message reached the other person is genuinely
|
|
613
|
+
unknown, and that is the answer to report, not a guess in either direction.
|
|
614
|
+
|
|
615
|
+
`sd_cancel_scheduled_message` works only while the state is `scheduled`. It
|
|
616
|
+
cannot stop a message that is already being sent, and it cannot unsend one that
|
|
617
|
+
has been sent: that text is in the other person's chat and nothing here takes it
|
|
618
|
+
back. If a sent message was wrong, say so in the chat. A cancel that names an id
|
|
619
|
+
belonging to somebody else is refused in exactly the same words as an id that
|
|
620
|
+
never existed, so a refusal never tells you whether another account's message is
|
|
621
|
+
real.
|
|
622
|
+
|
|
568
623
|
### Post scheduling and campaign time boundary
|
|
569
624
|
|
|
570
625
|
SignalDash supports an exact one-time future LinkedIn post through
|
|
@@ -579,10 +634,17 @@ never retried and never causes the post to be published again. Use
|
|
|
579
634
|
requires a fresh list, the exact id, approval, and
|
|
580
635
|
`confirm:true`; it works only while state is `scheduled`.
|
|
581
636
|
|
|
582
|
-
SignalDash
|
|
583
|
-
|
|
584
|
-
automatic follow-up, acceptance-triggered message
|
|
585
|
-
("double text") sequence
|
|
637
|
+
Everything SignalDash can schedule is one exact thing at one exact time:
|
|
638
|
+
`li_draft_post` for a post, `sd_schedule_message` for a message. There is no
|
|
639
|
+
recurring schedule, no automatic follow-up, no acceptance-triggered message and
|
|
640
|
+
no multi-message ("double text") sequence anywhere in this tool set, and the
|
|
641
|
+
first executable campaign scope has no user-selected future start date either.
|
|
642
|
+
Do not claim that any such action was queued, and do not try to assemble one out
|
|
643
|
+
of several scheduled messages into the same chat. That is not only forbidden, it
|
|
644
|
+
does not work: the first message landing is itself a change to the conversation,
|
|
645
|
+
so the second one, written against the thread as it was before, fails its
|
|
646
|
+
fire-time freshness check and parks as `needs_review` for a human. You get one
|
|
647
|
+
message sent and one waiting, which is worse than having scheduled nothing.
|
|
586
648
|
|
|
587
649
|
The common server write authority enforces the design-approved time controls
|
|
588
650
|
for every campaign action:
|
|
@@ -954,6 +1016,29 @@ and the command exits with failure.
|
|
|
954
1016
|
These controls run on the server. Prompt instructions, a new session, or a
|
|
955
1017
|
different calling order cannot bypass them.
|
|
956
1018
|
|
|
1019
|
+
### 403 `channel_required`
|
|
1020
|
+
|
|
1021
|
+
Meaning: this session has not yet completed its own QR scan, so it has no
|
|
1022
|
+
linked channel. An invite code proves an operator sent it to this person. It
|
|
1023
|
+
does not prove the person still controls a channel some earlier session
|
|
1024
|
+
linked, so a session that has not scanned cannot read stored data about the
|
|
1025
|
+
person or about third parties, and cannot change persistent settings. This
|
|
1026
|
+
covers `sd_contact_state`, `li_search_connections`, `sd_settings_set`, and
|
|
1027
|
+
every campaign and withdrawal-batch tool.
|
|
1028
|
+
|
|
1029
|
+
Comply:
|
|
1030
|
+
|
|
1031
|
+
1. Do not retry the tool and do not switch to another tool to read the same
|
|
1032
|
+
data.
|
|
1033
|
+
2. Relay the personal setup link to the human and have them finish the QR
|
|
1034
|
+
scan, exactly as in "First-time setup".
|
|
1035
|
+
3. Confirm with the status check in step 3 of that section, then retry.
|
|
1036
|
+
|
|
1037
|
+
`sd_settings_get`, `sd_budget_status`, `sd_auto_accept_status`, and
|
|
1038
|
+
`sd_voice_profile` are deliberately not behind this guard: the first three are
|
|
1039
|
+
how you verify a live session during setup, and `sd_voice_profile` reports
|
|
1040
|
+
`<channel>_not_connected` instead.
|
|
1041
|
+
|
|
957
1042
|
### 428 `read_before_send_required`
|
|
958
1043
|
|
|
959
1044
|
Meaning: this user has not successfully read the exact chat recently, or the
|
|
@@ -970,6 +1055,48 @@ Comply:
|
|
|
970
1055
|
|
|
971
1056
|
Do not satisfy this guard by reading a different chat with a similar name.
|
|
972
1057
|
|
|
1058
|
+
A LinkedIn or WhatsApp message send also returns this code when the most recent
|
|
1059
|
+
read of that chat predates thread-change detection and therefore carries no
|
|
1060
|
+
record of what the thread looked like. Read the chat again and send.
|
|
1061
|
+
|
|
1062
|
+
`sd_schedule_message` returns it for the same reason and one more: a LinkedIn
|
|
1063
|
+
read of fewer than 10 messages is not recorded as proof at all, so reading that
|
|
1064
|
+
thread again at the same shallow depth returns this code again. Read at `limit`
|
|
1065
|
+
10 or more. The refusal names the depth and the window it enforced; read what it
|
|
1066
|
+
says rather than repeating the call. The check runs before the `confirm` gate,
|
|
1067
|
+
so you find out you have to read the chat before you ask anyone to approve a
|
|
1068
|
+
message you cannot yet schedule.
|
|
1069
|
+
|
|
1070
|
+
### 409 `thread_changed`
|
|
1071
|
+
|
|
1072
|
+
Meaning: the exact chat moved after the read that authorized the send. Both
|
|
1073
|
+
`li_send_message` and `wa_send_message` re-read the thread immediately before
|
|
1074
|
+
sending and refuse unless it is still the thread that was read. A read inside
|
|
1075
|
+
the 30-minute window is not enough on its own: a thread that changed 29 minutes
|
|
1076
|
+
ago satisfies the window and is still not the conversation the draft was
|
|
1077
|
+
written against.
|
|
1078
|
+
|
|
1079
|
+
Comply:
|
|
1080
|
+
|
|
1081
|
+
1. Do not resend the same text.
|
|
1082
|
+
2. Re-read the exact chat and read the new messages.
|
|
1083
|
+
3. Revise the draft against what the thread now says, and obtain approval again.
|
|
1084
|
+
4. Send once, promptly.
|
|
1085
|
+
|
|
1086
|
+
A `502 thread_preflight_unavailable` on a send means that re-read itself failed,
|
|
1087
|
+
so whether the thread changed is unknown and nothing was sent. Retry the send
|
|
1088
|
+
later; do not treat it as a delivery. It also covers a re-read the provider
|
|
1089
|
+
answered successfully but in a shape SignalDash does not recognise, which is the
|
|
1090
|
+
same fact: no answer about whether the conversation moved. In that case a `428
|
|
1091
|
+
read_before_send_required` on the next attempt is expected too, because a read
|
|
1092
|
+
of an unrecognised shape records no authorization.
|
|
1093
|
+
|
|
1094
|
+
Your own send moves the thread too, so two messages in a row are refused unless
|
|
1095
|
+
you read the chat between them. This is not a glitch to work around: the second
|
|
1096
|
+
message was drafted against a conversation that no longer exists, if only
|
|
1097
|
+
because the first message is now in it. Read, confirm the first message landed
|
|
1098
|
+
as intended, then send the second. If you meant one message, send one message.
|
|
1099
|
+
|
|
973
1100
|
### 409 `duplicate_send`
|
|
974
1101
|
|
|
975
1102
|
Meaning: the exact message was already sent to that chat, or the exact email
|
|
@@ -1006,7 +1133,8 @@ claim that SignalDash has resolved a person across channels.
|
|
|
1006
1133
|
|
|
1007
1134
|
### 409 invitation and context preflight blocks
|
|
1008
1135
|
|
|
1009
|
-
`thread_changed
|
|
1136
|
+
`thread_changed` (also returned by a message send, see above),
|
|
1137
|
+
`already_connected`, `existing_conversation`,
|
|
1010
1138
|
`conversation_state_incomplete`, `invitation_already_pending`,
|
|
1011
1139
|
`inbound_invitation_pending`, `invitation_not_pending`,
|
|
1012
1140
|
`invitation_state_incomplete`, and `relationship_unverified` mean the exact
|