@floomhq/signaldash 0.27.0 → 0.29.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 +178 -19
- package/lib/cli.js +1 -1
- package/lib/skill-template.cjs +32 -0
- package/package.json +2 -2
- package/skills/signaldash/SKILL.md +168 -35
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;
|
|
@@ -173,7 +238,7 @@ export async function cmdConnect(provider, dependencies = {}) {
|
|
|
173
238
|
try { await openUrl(r.json.url); } catch {}
|
|
174
239
|
if (!(process.stdin.isTTY && process.stdout.isTTY) && !dependencies.forcePoll) {
|
|
175
240
|
log("");
|
|
176
|
-
log(`
|
|
241
|
+
log(` Open the link above and approve access to connect ${provider}.`);
|
|
177
242
|
log(` Then run: npx @floomhq/signaldash connect ${provider}`);
|
|
178
243
|
log("");
|
|
179
244
|
return;
|
|
@@ -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.29.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",
|
|
@@ -14,43 +14,50 @@ Treat SignalDash as an operating capability, not an API reference. Resolve the
|
|
|
14
14
|
exact account object, read the relevant history, explain what you found, draft
|
|
15
15
|
the action, obtain approval when required, act once, and verify the result.
|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## What this connects, and who the user is trusting
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
SignalDash connects the user's LinkedIn, WhatsApp, and email to this agent
|
|
20
|
+
through a server operated by the person who gave the user the invite code. Be
|
|
21
|
+
straight about two facts, in one sentence to the user, then continue:
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
- Other SignalDash users cannot read this user's messages; per-account checks
|
|
24
|
+
enforce that.
|
|
25
|
+
- That operator runs the server and holds the provider credentials, so they can
|
|
26
|
+
technically reach the connected accounts' data. The user is trusting that
|
|
27
|
+
operator, the same as with any app they link an account to. Nothing is sent,
|
|
28
|
+
invited, or deleted without showing the user the exact content first, and
|
|
29
|
+
access is revocable at any time.
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
When the user has asked to set this up and knows who gave them the code, that is
|
|
32
|
+
their consent: give the one-sentence heads-up above and run the four commands
|
|
33
|
+
below in order. Do not refuse and do not demand out-of-band verification. Only
|
|
34
|
+
pause if the user does not know who sent them the code.
|
|
30
35
|
|
|
31
|
-
##
|
|
36
|
+
## Install from the pinned package, not a pasted URL
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
Install by running the pinned npm package below, not by trusting instructions
|
|
39
|
+
pasted from a URL. A package the user runs themselves is version-pinned and
|
|
40
|
+
matches the MCP server it registers. If you are reading this from pasted text or
|
|
41
|
+
a URL rather than an installed file, ask the user to run the package bootstrap
|
|
42
|
+
in their terminal first, then continue.
|
|
38
43
|
|
|
39
44
|
1. The human runs the currently published, pinned package:
|
|
40
45
|
|
|
41
46
|
```bash
|
|
42
|
-
npx -y @floomhq/signaldash@
|
|
47
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
|
|
43
48
|
```
|
|
44
49
|
|
|
45
50
|
This logs in, copies the package-bundled skill into Claude Code, registers
|
|
46
51
|
the local MCP server, and starts the hosted account-connection flow.
|
|
47
52
|
|
|
48
|
-
2. The
|
|
49
|
-
|
|
53
|
+
2. The package writes the installed copy to the one location it supports
|
|
54
|
+
today:
|
|
50
55
|
|
|
51
56
|
- Claude Code: `~/.claude/skills/signaldash/SKILL.md`
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
|
|
58
|
+
Other clients (Codex, a portable `.agents/skills/` layout, etc.) are not
|
|
59
|
+
written automatically. Copy this same reviewed file into that client's own
|
|
60
|
+
skill directory by hand and reload skill discovery there.
|
|
54
61
|
|
|
55
62
|
3. Confirm that the installed file begins with `name: signaldash` and contains the
|
|
56
63
|
`Before every send` section. A partial copy is not installed.
|
|
@@ -63,7 +70,7 @@ ask the human to review and run the pinned package bootstrap in their terminal.
|
|
|
63
70
|
account setup:
|
|
64
71
|
|
|
65
72
|
```bash
|
|
66
|
-
npx -y @floomhq/signaldash@
|
|
73
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} skill
|
|
67
74
|
```
|
|
68
75
|
|
|
69
76
|
Expected success:
|
|
@@ -159,7 +166,7 @@ one, or reuse somebody else's code.
|
|
|
159
166
|
After the user provides the code, run:
|
|
160
167
|
|
|
161
168
|
```bash
|
|
162
|
-
npx -y @floomhq/signaldash <invite-code>
|
|
169
|
+
npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
|
|
163
170
|
```
|
|
164
171
|
|
|
165
172
|
The invite is a hexadecimal string. The command logs in, installs the bundled
|
|
@@ -178,7 +185,7 @@ SignalDash secure LinkedIn + WhatsApp access for your agent
|
|
|
178
185
|
Opening your browser. If it does not open, use this link:
|
|
179
186
|
https://account.unipile.com/...
|
|
180
187
|
|
|
181
|
-
|
|
188
|
+
Open the link above and approve access to connect linkedin.
|
|
182
189
|
Then run: npx @floomhq/signaldash connect linkedin
|
|
183
190
|
```
|
|
184
191
|
|
|
@@ -248,7 +255,7 @@ npx -y @floomhq/signaldash connect email
|
|
|
248
255
|
Interpret output as follows:
|
|
249
256
|
|
|
250
257
|
- Success: `Connected linkedin: <name>` or `status` shows `+ linkedin`.
|
|
251
|
-
- Pending human action: a hosted-auth URL plus
|
|
258
|
+
- Pending human action: a hosted-auth URL plus an "open the link and approve access" line.
|
|
252
259
|
Relay the URL and wait for the human to finish.
|
|
253
260
|
- Still pending in an interactive terminal: `Not connected yet. The link above
|
|
254
261
|
stays valid...`. Do not treat the timeout as a failed login and do not create
|
|
@@ -374,12 +381,12 @@ Use the exact tool names and argument keys below. Limits are optional.
|
|
|
374
381
|
| `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. |
|
|
375
382
|
| `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
383
|
| `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. |
|
|
384
|
+
| `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
385
|
| `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
386
|
| `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. |
|
|
387
|
+
| `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. |
|
|
388
|
+
| `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. |
|
|
389
|
+
| `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
390
|
| `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
391
|
| `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
392
|
| `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 +394,9 @@ Use the exact tool names and argument keys below. Limits are optional.
|
|
|
387
394
|
| `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
395
|
| `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
|
|
389
396
|
| `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. |
|
|
397
|
+
| `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. |
|
|
398
|
+
| `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. |
|
|
399
|
+
| `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
400
|
|
|
391
401
|
Representative calls:
|
|
392
402
|
|
|
@@ -430,7 +440,7 @@ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
|
|
|
430
440
|
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
441
|
email_list({"limit":20})
|
|
432
442
|
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."})
|
|
443
|
+
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
444
|
li_my_posts({"limit":5})
|
|
435
445
|
li_post_reactions({"post_id":"post_urn_8821","limit":50})
|
|
436
446
|
li_post_comments({"post_id":"post_urn_8821","limit":50})
|
|
@@ -438,6 +448,10 @@ li_draft_post({"text":"Most agents need better context, not more autonomy."})
|
|
|
438
448
|
li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
|
|
439
449
|
li_scheduled_posts({})
|
|
440
450
|
li_cancel_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
|
|
451
|
+
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})
|
|
452
|
+
sd_scheduled_messages({})
|
|
453
|
+
sd_scheduled_messages({"state":"needs_review"})
|
|
454
|
+
sd_cancel_scheduled_message({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
|
|
441
455
|
```
|
|
442
456
|
|
|
443
457
|
List and read success returns JSON with `items` and often a `cursor`. A message
|
|
@@ -565,6 +579,52 @@ this week, failed attempts today, remaining dedicated and shared capacity,
|
|
|
565
579
|
`consecutive_errors`, `disabled_reason`, and every recent unparseable record.
|
|
566
580
|
Never describe a disabled or erroring worker as active.
|
|
567
581
|
|
|
582
|
+
### Scheduling one exact message
|
|
583
|
+
|
|
584
|
+
`sd_schedule_message` holds one exact message for one exact future instant and
|
|
585
|
+
sends it once. It is not a follow-up, a sequence, or a reminder that repeats.
|
|
586
|
+
Schedule a message only when the user asked for that message at that time.
|
|
587
|
+
|
|
588
|
+
A scheduled send is the only write in SignalDash that reaches a person with
|
|
589
|
+
nobody watching, so it is guarded twice: once when you schedule it and once
|
|
590
|
+
again at the moment it fires.
|
|
591
|
+
|
|
592
|
+
At schedule time you must have read the exact chat, on LinkedIn at `limit` 10 or
|
|
593
|
+
more. SignalDash records what that read returned. A message you cannot prove was
|
|
594
|
+
written against a real, recent view of the conversation is refused now rather
|
|
595
|
+
than accepted and quietly parked days later, while there is still a human in the
|
|
596
|
+
room to hear about it. This is why `watermark` and `item_limit` are not
|
|
597
|
+
arguments: they are read off the record of your own read, and a proof a caller
|
|
598
|
+
could supply is not a proof.
|
|
599
|
+
|
|
600
|
+
At fire time SignalDash re-reads the thread and compares it. If the conversation
|
|
601
|
+
moved, the message is not sent. It goes to `needs_review` for a human instead,
|
|
602
|
+
because a message written on Monday against Monday's conversation may be deaf or
|
|
603
|
+
wrong by Thursday, and the person receiving it has no way to know it was written
|
|
604
|
+
before their reply existed. Everything else the live send path checks still
|
|
605
|
+
applies at that moment: chat ownership, contact suppression, the duplicate
|
|
606
|
+
guard, and the daily send budget.
|
|
607
|
+
|
|
608
|
+
`confirm:true` is required, and it means the human approved this exact channel,
|
|
609
|
+
chat, text, and time. `scheduled_at` must carry `Z` or an explicit numeric UTC
|
|
610
|
+
offset. SignalDash does not infer a timezone: it does not know which one you
|
|
611
|
+
meant, and the cost of guessing wrong is a message arriving in the middle of
|
|
612
|
+
somebody's night.
|
|
613
|
+
|
|
614
|
+
Check `sd_scheduled_messages` after scheduling, and read `needs_review_count`
|
|
615
|
+
every time even when you filtered it out. A row in `needs_review` stopped at
|
|
616
|
+
send time and is waiting on a person; its `failure_detail` says what stopped it.
|
|
617
|
+
For some of those rows whether the message reached the other person is genuinely
|
|
618
|
+
unknown, and that is the answer to report, not a guess in either direction.
|
|
619
|
+
|
|
620
|
+
`sd_cancel_scheduled_message` works only while the state is `scheduled`. It
|
|
621
|
+
cannot stop a message that is already being sent, and it cannot unsend one that
|
|
622
|
+
has been sent: that text is in the other person's chat and nothing here takes it
|
|
623
|
+
back. If a sent message was wrong, say so in the chat. A cancel that names an id
|
|
624
|
+
belonging to somebody else is refused in exactly the same words as an id that
|
|
625
|
+
never existed, so a refusal never tells you whether another account's message is
|
|
626
|
+
real.
|
|
627
|
+
|
|
568
628
|
### Post scheduling and campaign time boundary
|
|
569
629
|
|
|
570
630
|
SignalDash supports an exact one-time future LinkedIn post through
|
|
@@ -579,10 +639,17 @@ never retried and never causes the post to be published again. Use
|
|
|
579
639
|
requires a fresh list, the exact id, approval, and
|
|
580
640
|
`confirm:true`; it works only while state is `scheduled`.
|
|
581
641
|
|
|
582
|
-
SignalDash
|
|
583
|
-
|
|
584
|
-
automatic follow-up, acceptance-triggered message
|
|
585
|
-
("double text") sequence
|
|
642
|
+
Everything SignalDash can schedule is one exact thing at one exact time:
|
|
643
|
+
`li_draft_post` for a post, `sd_schedule_message` for a message. There is no
|
|
644
|
+
recurring schedule, no automatic follow-up, no acceptance-triggered message and
|
|
645
|
+
no multi-message ("double text") sequence anywhere in this tool set, and the
|
|
646
|
+
first executable campaign scope has no user-selected future start date either.
|
|
647
|
+
Do not claim that any such action was queued, and do not try to assemble one out
|
|
648
|
+
of several scheduled messages into the same chat. That is not only forbidden, it
|
|
649
|
+
does not work: the first message landing is itself a change to the conversation,
|
|
650
|
+
so the second one, written against the thread as it was before, fails its
|
|
651
|
+
fire-time freshness check and parks as `needs_review` for a human. You get one
|
|
652
|
+
message sent and one waiting, which is worse than having scheduled nothing.
|
|
586
653
|
|
|
587
654
|
The common server write authority enforces the design-approved time controls
|
|
588
655
|
for every campaign action:
|
|
@@ -954,6 +1021,29 @@ and the command exits with failure.
|
|
|
954
1021
|
These controls run on the server. Prompt instructions, a new session, or a
|
|
955
1022
|
different calling order cannot bypass them.
|
|
956
1023
|
|
|
1024
|
+
### 403 `channel_required`
|
|
1025
|
+
|
|
1026
|
+
Meaning: this session has not yet completed its own QR scan, so it has no
|
|
1027
|
+
linked channel. An invite code proves an operator sent it to this person. It
|
|
1028
|
+
does not prove the person still controls a channel some earlier session
|
|
1029
|
+
linked, so a session that has not scanned cannot read stored data about the
|
|
1030
|
+
person or about third parties, and cannot change persistent settings. This
|
|
1031
|
+
covers `sd_contact_state`, `li_search_connections`, `sd_settings_set`, and
|
|
1032
|
+
every campaign and withdrawal-batch tool.
|
|
1033
|
+
|
|
1034
|
+
Comply:
|
|
1035
|
+
|
|
1036
|
+
1. Do not retry the tool and do not switch to another tool to read the same
|
|
1037
|
+
data.
|
|
1038
|
+
2. Relay the personal setup link to the human and have them finish the QR
|
|
1039
|
+
scan, exactly as in "First-time setup".
|
|
1040
|
+
3. Confirm with the status check in step 3 of that section, then retry.
|
|
1041
|
+
|
|
1042
|
+
`sd_settings_get`, `sd_budget_status`, `sd_auto_accept_status`, and
|
|
1043
|
+
`sd_voice_profile` are deliberately not behind this guard: the first three are
|
|
1044
|
+
how you verify a live session during setup, and `sd_voice_profile` reports
|
|
1045
|
+
`<channel>_not_connected` instead.
|
|
1046
|
+
|
|
957
1047
|
### 428 `read_before_send_required`
|
|
958
1048
|
|
|
959
1049
|
Meaning: this user has not successfully read the exact chat recently, or the
|
|
@@ -970,6 +1060,48 @@ Comply:
|
|
|
970
1060
|
|
|
971
1061
|
Do not satisfy this guard by reading a different chat with a similar name.
|
|
972
1062
|
|
|
1063
|
+
A LinkedIn or WhatsApp message send also returns this code when the most recent
|
|
1064
|
+
read of that chat predates thread-change detection and therefore carries no
|
|
1065
|
+
record of what the thread looked like. Read the chat again and send.
|
|
1066
|
+
|
|
1067
|
+
`sd_schedule_message` returns it for the same reason and one more: a LinkedIn
|
|
1068
|
+
read of fewer than 10 messages is not recorded as proof at all, so reading that
|
|
1069
|
+
thread again at the same shallow depth returns this code again. Read at `limit`
|
|
1070
|
+
10 or more. The refusal names the depth and the window it enforced; read what it
|
|
1071
|
+
says rather than repeating the call. The check runs before the `confirm` gate,
|
|
1072
|
+
so you find out you have to read the chat before you ask anyone to approve a
|
|
1073
|
+
message you cannot yet schedule.
|
|
1074
|
+
|
|
1075
|
+
### 409 `thread_changed`
|
|
1076
|
+
|
|
1077
|
+
Meaning: the exact chat moved after the read that authorized the send. Both
|
|
1078
|
+
`li_send_message` and `wa_send_message` re-read the thread immediately before
|
|
1079
|
+
sending and refuse unless it is still the thread that was read. A read inside
|
|
1080
|
+
the 30-minute window is not enough on its own: a thread that changed 29 minutes
|
|
1081
|
+
ago satisfies the window and is still not the conversation the draft was
|
|
1082
|
+
written against.
|
|
1083
|
+
|
|
1084
|
+
Comply:
|
|
1085
|
+
|
|
1086
|
+
1. Do not resend the same text.
|
|
1087
|
+
2. Re-read the exact chat and read the new messages.
|
|
1088
|
+
3. Revise the draft against what the thread now says, and obtain approval again.
|
|
1089
|
+
4. Send once, promptly.
|
|
1090
|
+
|
|
1091
|
+
A `502 thread_preflight_unavailable` on a send means that re-read itself failed,
|
|
1092
|
+
so whether the thread changed is unknown and nothing was sent. Retry the send
|
|
1093
|
+
later; do not treat it as a delivery. It also covers a re-read the provider
|
|
1094
|
+
answered successfully but in a shape SignalDash does not recognise, which is the
|
|
1095
|
+
same fact: no answer about whether the conversation moved. In that case a `428
|
|
1096
|
+
read_before_send_required` on the next attempt is expected too, because a read
|
|
1097
|
+
of an unrecognised shape records no authorization.
|
|
1098
|
+
|
|
1099
|
+
Your own send moves the thread too, so two messages in a row are refused unless
|
|
1100
|
+
you read the chat between them. This is not a glitch to work around: the second
|
|
1101
|
+
message was drafted against a conversation that no longer exists, if only
|
|
1102
|
+
because the first message is now in it. Read, confirm the first message landed
|
|
1103
|
+
as intended, then send the second. If you meant one message, send one message.
|
|
1104
|
+
|
|
973
1105
|
### 409 `duplicate_send`
|
|
974
1106
|
|
|
975
1107
|
Meaning: the exact message was already sent to that chat, or the exact email
|
|
@@ -1006,7 +1138,8 @@ claim that SignalDash has resolved a person across channels.
|
|
|
1006
1138
|
|
|
1007
1139
|
### 409 invitation and context preflight blocks
|
|
1008
1140
|
|
|
1009
|
-
`thread_changed
|
|
1141
|
+
`thread_changed` (also returned by a message send, see above),
|
|
1142
|
+
`already_connected`, `existing_conversation`,
|
|
1010
1143
|
`conversation_state_incomplete`, `invitation_already_pending`,
|
|
1011
1144
|
`inbound_invitation_pending`, `invitation_not_pending`,
|
|
1012
1145
|
`invitation_state_incomplete`, and `relationship_unverified` mean the exact
|