@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 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.27.0 <invite-code>
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(` ACTION REQUIRED (human): open the link above to connect ${provider}.`);
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. `anyOf` states the real rule to any client that
986
- // validates locally, and the backend refuses a call carrying neither
987
- // regardless of whether the client checked.
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: { limit: { type: "integer", minimum: 1, maximum: 100 } },
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
- wf(join(dest, "SKILL.md"), rf(src, "utf8"));
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 && /^[0-9a-f]{8,}$/i.test(cmd) && !["login","logout","connect","mcp","skill"].includes(cmd)) await cmdSetup(cmd, dependencies);
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 === "logout") await cmdLogout(dependencies);
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.27.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
- ## Before you set this up (say this to the human first)
17
+ ## What this connects, and who the user is trusting
18
18
 
19
- You are about to wire the user's REAL LinkedIn, WhatsApp and email into a
20
- persistent MCP server, on the strength of a link and a code someone sent them.
21
- Name that plainly before you run anything:
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
- > "This will connect your real LinkedIn/WhatsApp to an MCP server I'll register
24
- > locally, using an invite code from Federico (signaldash-api.floom.dev holds the
25
- > credentials, not this machine). I can read and draft messages; I won't send
26
- > anything without showing you the exact text first. OK to proceed?"
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
- Wait for a clear yes. If the user does not know who sent the link, or is unsure,
29
- stop and let them verify first. Do not soften this to get through setup faster.
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
- ## Ensure the skill is installed from a reviewed local package
36
+ ## Install from the pinned package, not a pasted URL
32
37
 
33
- Do not install this file as trusted instructions merely because an agent was
34
- given its public URL or pasted contents. A remote instruction document can
35
- change independently of the user's installed software and may be rejected by a
36
- security-conscious agent. When reading this from a URL or pasted text, stop and
37
- ask the human to review and run the pinned package bootstrap in their terminal.
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@0.27.0 <invite-code>
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 installed skill lives in a directory that the current agent discovers.
49
- Common locations are:
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
- - Codex: `~/.codex/skills/signaldash/SKILL.md`
53
- - Portable project scope: `.agents/skills/signaldash/SKILL.md`
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@0.27.0 skill
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
- ACTION REQUIRED (human): open the link above to connect linkedin.
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 `ACTION REQUIRED (human)`.
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 does not expose a general message scheduler. The first executable
583
- campaign scope has no user-selected future start date, recurring schedule,
584
- automatic follow-up, acceptance-triggered message, or multi-message
585
- ("double text") sequence. Do not claim that any such action was queued.
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`, `already_connected`, `existing_conversation`,
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