@floomhq/signaldash 0.13.0 → 0.27.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 +249 -10
- package/bin/sd.mjs +1006 -38
- package/lib/cli.js +16 -22
- package/lib/config-file.js +114 -0
- package/lib/mcp.js +12 -2
- package/package.json +3 -3
- package/skills/signaldash/SKILL.md +1033 -62
- package/skills/signaldash-safe-usage/SKILL.md +74 -9
package/README.md
CHANGED
|
@@ -82,20 +82,25 @@ The MCP server uses the user token created by `login`. It cannot access a
|
|
|
82
82
|
LinkedIn, WhatsApp, or email account until that channel has been connected for
|
|
83
83
|
the same logged-in user.
|
|
84
84
|
|
|
85
|
-
##
|
|
85
|
+
## Operating skill distribution
|
|
86
86
|
|
|
87
87
|
The canonical [`signaldash` skill](skills/signaldash/SKILL.md) teaches an agent
|
|
88
88
|
when to use SignalDash, how to persist the skill, complete setup, interpret
|
|
89
89
|
connection state, operate every tool, handle server guards, and run safe worked
|
|
90
|
-
flows.
|
|
90
|
+
flows. The one-command setup installs the skill from the same pinned npm package
|
|
91
|
+
the human chose to execute:
|
|
91
92
|
|
|
92
|
-
```
|
|
93
|
-
|
|
93
|
+
```bash
|
|
94
|
+
npx -y @floomhq/signaldash@0.27.0 <invite-code>
|
|
94
95
|
```
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
Run that command in a terminal, not in an agent chat. Do not ask an agent to
|
|
98
|
+
fetch a remote `SKILL.md` and install it as trusted instructions. The public
|
|
99
|
+
`https://signaldash.dev/SKILL.md` endpoint remains available for transparent
|
|
100
|
+
human review only.
|
|
101
|
+
|
|
102
|
+
Other clients can install the reviewed bundled file from the package into their
|
|
103
|
+
documented skill directory and reload skill discovery. The narrower
|
|
99
104
|
[`signaldash-safe-usage` skill](skills/signaldash-safe-usage/SKILL.md) remains
|
|
100
105
|
available for clients that separate operating and send-safety instructions.
|
|
101
106
|
|
|
@@ -106,20 +111,176 @@ SignalDash exposes:
|
|
|
106
111
|
- `li_list_chats`
|
|
107
112
|
- `li_read_messages(chat_id)`
|
|
108
113
|
- `li_send_message(chat_id, text)`
|
|
114
|
+
- `li_send_invitation(provider_id, note?, confirm?)`
|
|
115
|
+
- `li_invitations_received(limit?, cursor?)`
|
|
116
|
+
- `li_accept_invitation(invitation_id, confirm)`
|
|
117
|
+
- `li_invitations_sent(limit?, cursor?)`
|
|
118
|
+
- `li_withdraw_invitation(invitation_id, confirm)`
|
|
119
|
+
- `sd_contact_state(channel, identifiers, action?, reason?, confirm?)`
|
|
120
|
+
- `sd_budget_status()`
|
|
121
|
+
- `sd_settings_get()`
|
|
122
|
+
- `sd_settings_set(auto_accept_linkedin, auto_accept_linkedin_filters?, confirm)`
|
|
123
|
+
- `sd_auto_accept_status()`
|
|
124
|
+
- `sd_voice_profile(channel, force_recompute?)`
|
|
125
|
+
- `li_search_connections(query, filters?, limit?)`
|
|
126
|
+
- `li_discover_people(query, filters?, limit?, confirm?)`
|
|
127
|
+
- `li_create_invitation_batch(source_label, time_zone, targets)`
|
|
128
|
+
- `li_get_invitation_batch(batch_id)`
|
|
129
|
+
- `li_cancel_invitation_batch(batch_id, approval_view_hash, confirm)`
|
|
130
|
+
- `sd_campaign_create(source_label, time_zone, messages, target_source?, targets?, engagers?, invite_ttl_days?)`
|
|
131
|
+
- `sd_campaign_preview(campaign_id)`
|
|
132
|
+
- `sd_campaign_approve(campaign_id, confirm_token)`
|
|
133
|
+
- `sd_campaign_status(campaign_id)`
|
|
134
|
+
- `sd_campaign_cancel(campaign_id, approval_view_hash, confirm)`
|
|
135
|
+
- `sd_withdrawal_batch_create(account, exclude, time_zone, older_than_days?, limit?, source_label?, allow_unmatched_exclusions?)`
|
|
136
|
+
- `sd_withdrawal_batch_status(withdrawal_batch_id)`
|
|
137
|
+
- `sd_withdrawal_batch_approve(withdrawal_batch_id, confirm_token)`
|
|
138
|
+
- `sd_withdrawal_batch_cancel(withdrawal_batch_id, approval_view_hash, confirm)`
|
|
109
139
|
- `wa_list_chats`
|
|
110
140
|
- `wa_read_messages(chat_id)`
|
|
111
|
-
- `
|
|
141
|
+
- `wa_get_attachment(chat_id, message_id, attachment_id)`
|
|
142
|
+
- `wa_transcribe_voice(chat_id, message_id, attachment_id)`
|
|
143
|
+
- `wa_send_message(chat_id, text?, attachments?)`
|
|
144
|
+
- `wa_delete_message(chat_id, message_id)`
|
|
145
|
+
- `wa_delete_messages(messages)`
|
|
112
146
|
- `email_list(limit)`
|
|
113
147
|
- `email_read(thread_id, limit)`
|
|
114
148
|
- `email_send(to, subject, body)`
|
|
115
149
|
- `li_my_posts(limit, member_id)`
|
|
116
150
|
- `li_post_reactions(post_id, limit)`
|
|
117
151
|
- `li_post_comments(post_id, limit)`
|
|
118
|
-
- `li_draft_post(text, publish)`
|
|
152
|
+
- `li_draft_post(text, publish, scheduled_at?, mentions?, attachments?, first_comment?)`
|
|
153
|
+
- `li_set_scheduled_post_first_comment(id, first_comment, confirm)`
|
|
154
|
+
- `li_scheduled_posts()`
|
|
155
|
+
- `li_cancel_scheduled_post(id, confirm)`
|
|
119
156
|
|
|
120
157
|
Every operation runs through the hosted SignalDash backend. Agents never
|
|
121
158
|
receive the Unipile access key.
|
|
122
159
|
|
|
160
|
+
A WhatsApp message can carry files. `wa_send_message` takes up to 4 exact
|
|
161
|
+
`{filename, content_type, content_base64}` attachments, at most 16 MiB per file
|
|
162
|
+
and 16 MiB per message, and accepts PNG, JPEG, WebP, GIF, PDF, CSV, plain text,
|
|
163
|
+
JSON, xlsx and zip. `text` is the caption and may be omitted when a file is
|
|
164
|
+
attached, but a call carrying neither text nor an attachment is refused rather
|
|
165
|
+
than sent as an empty message. There is no attachment lane: a send with a file
|
|
166
|
+
runs the same ownership check, the same read-before-send guard, the same
|
|
167
|
+
duplicate guard and the same daily budget as a text send, and is recorded the
|
|
168
|
+
same way. Files are validated before anything is reserved, so a malformed or
|
|
169
|
+
oversized attachment costs no send budget and never reaches the provider, and
|
|
170
|
+
every refusal names the exact rule and the exact file that tripped it. Nothing
|
|
171
|
+
is ever truncated or dropped silently. LinkedIn messages carry text only, and
|
|
172
|
+
`li_send_message` refuses an `attachments` argument instead of ignoring it.
|
|
173
|
+
|
|
174
|
+
A send is not idempotent, so an unconfirmed one is not silently retryable. If
|
|
175
|
+
the provider times out, fails with a 5xx, or answers 2xx with a body that will
|
|
176
|
+
not parse, the message may well have been delivered: SignalDash records the
|
|
177
|
+
attempt before the request leaves and refuses an identical retry with
|
|
178
|
+
`409 send_outcome_unknown`. Read the chat again, and only if the message is
|
|
179
|
+
genuinely absent, resend the identical payload with `confirm_resend:true`. That
|
|
180
|
+
re-read is enforced rather than merely instructed: a resend whose most recent
|
|
181
|
+
read of the chat predates the failed attempt is refused with
|
|
182
|
+
`428 reread_after_failed_send_required`, since a read taken beforehand cannot
|
|
183
|
+
show whether the message arrived. Time does not
|
|
184
|
+
clear that record, because waiting does not turn a delivered message into an
|
|
185
|
+
undelivered one; only the caller confirming what the thread actually shows
|
|
186
|
+
does. A provider that answers with a 4xx refused the message outright, so that
|
|
187
|
+
record is cleared and the send stays retryable. Two 4xx answers are excepted,
|
|
188
|
+
as a judgement call rather than on documented provider semantics: `408` and
|
|
189
|
+
`429` can both arrive after a message was already accepted and forwarded, so
|
|
190
|
+
they are treated as unknown outcomes too.
|
|
191
|
+
Override the ceilings with `SIGNALDASH_MESSAGE_ATTACHMENT_MAX_BYTES` and
|
|
192
|
+
`SIGNALDASH_MESSAGE_ATTACHMENT_MAX_TOTAL_BYTES`; raising either also needs
|
|
193
|
+
`SIGNALDASH_MESSAGE_SEND_MAX_BODY_BYTES` raised to match, because base64 costs a
|
|
194
|
+
third on top of the bytes and a body over that cap is refused with
|
|
195
|
+
`413 request_too_large` before it is parsed.
|
|
196
|
+
|
|
197
|
+
Future LinkedIn posts are persisted by the backend only after the exact text,
|
|
198
|
+
offset-qualified publish time, mentions, image attachments, and optional first
|
|
199
|
+
comment have human approval. The stored image bytes and comment text cannot
|
|
200
|
+
drift after approval. The scheduler publishes the comment through the same
|
|
201
|
+
connected LinkedIn account immediately after the post. It persists the post ID
|
|
202
|
+
before attempting the comment, so a comment failure never republishes the post.
|
|
203
|
+
The scheduler
|
|
204
|
+
uses the same sender binding, action ledger, duplicate guard, daily budget, and
|
|
205
|
+
provider-warning lock as immediate publishing. An interrupted or ambiguous
|
|
206
|
+
execution fails closed and is never retried automatically.
|
|
207
|
+
|
|
208
|
+
A campaign is one connection request per approved person, then the approved
|
|
209
|
+
message once a fresh profile read proves that person accepted, then an optional
|
|
210
|
+
follow-up that stops the moment they reply. A pending invitation that merely
|
|
211
|
+
disappeared is never treated as an acceptance. Campaign writes consume the same
|
|
212
|
+
per-sender daily budget as manual sends, obey the Monday-Friday 09:00-17:00
|
|
213
|
+
sender-local window and the 90-180 second pacing interval, and cannot start
|
|
214
|
+
without a human approval recorded on the authenticated approval page. An agent
|
|
215
|
+
can present that page and carry back the one-time code it mints, but can never
|
|
216
|
+
approve on its own.
|
|
217
|
+
|
|
218
|
+
A withdrawal sweep clears a backlog of old pending SENT invitations. It uses the
|
|
219
|
+
same object as an invitation batch with the arrow reversed: freeze the exact
|
|
220
|
+
invitation ids now, one human approval bound to the rendered view, then one
|
|
221
|
+
withdrawal per tick. Two things it will never claim, because both contradict
|
|
222
|
+
LinkedIn's own documentation: withdrawing does **not** lift an active sending
|
|
223
|
+
restriction, so this buys no sending capacity; and after withdrawing, that
|
|
224
|
+
member cannot be re-invited for up to three weeks. The exclusion list is a
|
|
225
|
+
required field rather than an option, so protecting nobody is a deliberate
|
|
226
|
+
choice and not a default. LinkedIn exposes invitation age as a bucket ("sent 4
|
|
227
|
+
months ago"), never a date, so the filter selects only when the whole bucket
|
|
228
|
+
clears the threshold and the review page reports what that conservatism held
|
|
229
|
+
back. The sweep runs on its own daily allowance and never consumes the send
|
|
230
|
+
budget, and any 403, 429, provider warning, or account status change stops it
|
|
231
|
+
entirely.
|
|
232
|
+
|
|
233
|
+
LinkedIn invitation auto-accept is off for every user until that user
|
|
234
|
+
explicitly enables it with `confirm:true`. The optional filters are an exact
|
|
235
|
+
public-identifier allowlist and description-keyword matches; when both groups
|
|
236
|
+
are configured, both must match. The single backend worker reads one bounded
|
|
237
|
+
received-invitation page, accepts at most five invitations per run and ten per
|
|
238
|
+
UTC day by default, jitters every action, and also consumes the shared daily
|
|
239
|
+
LinkedIn action budget. Three consecutive parse or provider errors disable the
|
|
240
|
+
setting. `sd_auto_accept_status` exposes accepted counts today and this week,
|
|
241
|
+
failed attempts today, remaining capacity, the stop reason, and sanitized
|
|
242
|
+
records for invitations whose provider shape could not be parsed. Both the current nested
|
|
243
|
+
`specifics.shared_secret` and legacy top-level shape are supported; secrets are
|
|
244
|
+
never returned or logged.
|
|
245
|
+
|
|
246
|
+
`li_search_connections` is the zero-cost, zero-provider-risk list-building
|
|
247
|
+
path. It searches only the authenticated user's stored connection snapshot by
|
|
248
|
+
name, headline, or company, supports company/headline-keyword/date filters, and
|
|
249
|
+
joins exact local contact state. It never calls LinkedIn, Unipile, or a paid
|
|
250
|
+
discovery API. When no snapshot exists, it points to the paced sync below
|
|
251
|
+
without starting provider work.
|
|
252
|
+
|
|
253
|
+
`li_discover_people` is the capped paid fallback for people beyond a completed
|
|
254
|
+
stored network. SignalDash runs the same role/company intent against the local
|
|
255
|
+
snapshot first and returns local matches without spending. With no local
|
|
256
|
+
matches, the first call returns an exact paid preview. An identical
|
|
257
|
+
`confirm:true` call can make one serialized HarvestAPI profile-search request.
|
|
258
|
+
That reservation consumes the preview atomically, so every later paid request
|
|
259
|
+
requires a fresh preview and approval.
|
|
260
|
+
Durable per-user pacing, per-user and tenant request caps, and a tenant-wide
|
|
261
|
+
micro-dollar cap are reserved atomically before the request. Hidden cards,
|
|
262
|
+
malformed identities, exact stored connections, and exact contact-state rows
|
|
263
|
+
already marked connected are excluded. Results are read-only planning evidence
|
|
264
|
+
and cannot become invitation-batch targets automatically.
|
|
265
|
+
|
|
266
|
+
Invitation batches are the narrow executable campaign boundary. An agent turns
|
|
267
|
+
the human's request into 1–10 exact canonical LinkedIn Classic profile URLs,
|
|
268
|
+
inclusion reasons, and optional notes, then creates an immutable durable
|
|
269
|
+
preview. The agent can inspect or cancel the batch but cannot approve it. A
|
|
270
|
+
human opens the returned absolute review URL, reauthenticates with the SignalDash
|
|
271
|
+
invite credential, selects unchecked eligible targets, and approves the exact
|
|
272
|
+
stored payload for up to 24 hours. Five failed credential attempts block that
|
|
273
|
+
batch's authentication surface for 15 minutes. The single backend worker
|
|
274
|
+
rechecks sender binding, exact identity, contact state, prior actions, chats,
|
|
275
|
+
invitations, daily/weekly budgets, weekday work window, pacing, and the tenant
|
|
276
|
+
breaker before each at-most-once write. A tenant provider-authentication
|
|
277
|
+
failure opens the persisted breaker immediately; an open breaker rejects new
|
|
278
|
+
batches and stops queued preview work before provider reads.
|
|
279
|
+
|
|
280
|
+
This is not a general campaign engine. It has no pause/resume, priority,
|
|
281
|
+
future start, recurring schedule, acceptance polling, automatic message,
|
|
282
|
+
follow-up, discovery-to-send bridge, or runtime target/text generation.
|
|
283
|
+
|
|
123
284
|
The CLI also provides a paced, resumable LinkedIn connections export:
|
|
124
285
|
|
|
125
286
|
```bash
|
|
@@ -135,17 +296,65 @@ workflow around that runtime control:
|
|
|
135
296
|
`resets_at`, plus matching `X-RateLimit-*` headers. When the per-user cap is
|
|
136
297
|
exhausted, SignalDash returns HTTP 429 with `code: "rate_limit_exceeded"` and
|
|
137
298
|
`Retry-After`. Usage is persisted server-side and resets at midnight UTC.
|
|
299
|
+
- The caps are per channel lane: LinkedIn actions, WhatsApp and email sends
|
|
300
|
+
together, and message deletes each spend their own daily budget. A `429` names
|
|
301
|
+
the cap it hit; it is never a statement about another lane. Two caveats worth
|
|
302
|
+
knowing: on the day a deployment first upgrades past the lane split, that
|
|
303
|
+
day's existing count is still read on the LinkedIn side (it is ambiguous, and
|
|
304
|
+
the conservative reading is the safe one) and clears at the next UTC midnight;
|
|
305
|
+
and the WhatsApp/email lane is keyed per access token while the LinkedIn
|
|
306
|
+
ledger is keyed per user, so several live tokens for one user get one LinkedIn
|
|
307
|
+
budget but a WhatsApp budget each.
|
|
138
308
|
- Read the exact thread before every send.
|
|
309
|
+
- Preview an exact invitation target and note before sending it.
|
|
310
|
+
- List the exact received or sent invitation before accepting or withdrawing it.
|
|
139
311
|
- Email sends accept one recipient at a time and require a recent
|
|
140
312
|
`email_read` containing that address.
|
|
141
313
|
- Never infer a recipient from a partial name.
|
|
142
314
|
- Never send a duplicate or retry an ambiguous timeout without re-reading.
|
|
315
|
+
- Invitation auto-accept remains off until explicitly enabled, consumes both
|
|
316
|
+
its dedicated daily cap and the shared LinkedIn action cap, and disables
|
|
317
|
+
itself after three consecutive parse or provider errors.
|
|
143
318
|
- Do not parallelize sends or work around a rate limit.
|
|
319
|
+
- Do not bypass paid-discovery previews, pacing, request caps, or cost caps.
|
|
144
320
|
- Stop on a provider warning, checkpoint, restriction, HTTP 403, or HTTP 429.
|
|
145
321
|
- Avoid bulk profile reads, copied message bursts, cold-account automation,
|
|
146
322
|
and unsolicited WhatsApp messaging.
|
|
147
323
|
|
|
148
|
-
|
|
324
|
+
LinkedIn invitation writes are single-object actions. SignalDash serializes
|
|
325
|
+
them with other LinkedIn writes, requires an exact preview or list read,
|
|
326
|
+
applies the shared daily action cap, and enforces a separate 100-attempt weekly
|
|
327
|
+
policy by default. For a new invitation, jitter completes before the final
|
|
328
|
+
preflight and action reservation; that preflight verifies relationship state,
|
|
329
|
+
pending invitations in both directions, and absence of an existing one-to-one
|
|
330
|
+
chat. Provider timeouts become non-retryable `outcome_unknown` locks.
|
|
331
|
+
|
|
332
|
+
The first campaign time boundary is intentionally narrower than scheduled
|
|
333
|
+
messaging. Campaign invitations require a valid sender IANA timezone, run only
|
|
334
|
+
Monday through Friday from 09:00 to 17:00 sender-local time, and reserve a new
|
|
335
|
+
90–180 second per-sender pacing interval atomically with every attempted
|
|
336
|
+
action. Timing is checked before final provider preflight and again during the
|
|
337
|
+
write reservation. Downtime never creates a catch-up burst. Future start
|
|
338
|
+
dates, recurring schedules, automatic follow-ups, acceptance-triggered
|
|
339
|
+
messages, and multi-message sequences are not exposed.
|
|
340
|
+
|
|
341
|
+
Deleting a WhatsApp message is a write, and an irreversible one, so it is
|
|
342
|
+
treated as such. SignalDash proves the chat belongs to the connected account,
|
|
343
|
+
proves the message sits in that exact chat, and proves the account wrote it,
|
|
344
|
+
before anything is removed; someone else's message is refused with `403
|
|
345
|
+
message_not_own`. Every attempt is appended to that user's own durable audit
|
|
346
|
+
trail with the chat, the message, the outcome and the timestamp, including the
|
|
347
|
+
attempts that fail and the ones whose outcome the network left unknown. A
|
|
348
|
+
delete already recorded for the same chat and message is refused with `409
|
|
349
|
+
duplicate_delete` rather than replayed. Deletes spend a separate daily budget
|
|
350
|
+
(`SIGNALDASH_DAILY_DELETE_LIMIT`, default 200) so a cleanup can never consume
|
|
351
|
+
the sending capacity it exists to repair. `wa_delete_messages` runs at most 200
|
|
352
|
+
entries strictly one at a time, pauses `SIGNALDASH_WA_DELETE_PACE_MS` between
|
|
353
|
+
them, and stops at `SIGNALDASH_WA_DELETE_BATCH_DEADLINE_MS`, returning the
|
|
354
|
+
untouched remainder as `skipped` with `code: batch_deadline` so the caller can
|
|
355
|
+
resume exactly those. WhatsApp applies its own time and role limits to deleting
|
|
356
|
+
for everyone and can answer successfully without removing anything, so re-read
|
|
357
|
+
the chat to confirm.
|
|
149
358
|
|
|
150
359
|
## Development
|
|
151
360
|
|
|
@@ -154,3 +363,33 @@ npm test
|
|
|
154
363
|
npm run check
|
|
155
364
|
npm pack --dry-run
|
|
156
365
|
```
|
|
366
|
+
|
|
367
|
+
`wa_transcribe_voice` runs on the SignalDash host and has two backends. The
|
|
368
|
+
default, `gemini`, sends the audio to Vertex AI using that host's own
|
|
369
|
+
credentials; it is the one that survives German speech with English technical
|
|
370
|
+
terms mixed into it, and being network-bound it does not compete with the
|
|
371
|
+
gateway for CPU. The fallback, `whisper`, needs `python3` with `faster-whisper`
|
|
372
|
+
installed on the host and runs the `small` CPU int8 model, which is much weaker
|
|
373
|
+
on that kind of code-switching. Every response therefore names the engine that
|
|
374
|
+
produced the text in `backend`, and a `gemini` request that had to fall back
|
|
375
|
+
also explains why in `fallback_reason`.
|
|
376
|
+
|
|
377
|
+
Pick the engine per request with the optional `backend` argument (`gemini` or
|
|
378
|
+
`whisper`), or set the default with `SIGNALDASH_TRANSCRIBE_BACKEND`. Override
|
|
379
|
+
the rest with `SIGNALDASH_TRANSCRIBE_PYTHON`, `SIGNALDASH_TRANSCRIBE_MODEL`
|
|
380
|
+
(local model), `SIGNALDASH_TRANSCRIBE_GEMINI_MODEL`,
|
|
381
|
+
`SIGNALDASH_TRANSCRIBE_GEMINI_PROJECT`,
|
|
382
|
+
`SIGNALDASH_TRANSCRIBE_GEMINI_LOCATION`,
|
|
383
|
+
`SIGNALDASH_TRANSCRIBE_GEMINI_TIMEOUT_S` (total budget for the Vertex call
|
|
384
|
+
including retries, default 240s, and it has to stay under
|
|
385
|
+
`SIGNALDASH_TRANSCRIBE_TIMEOUT_MS`, default 420000, so the local fallback still
|
|
386
|
+
fits underneath), `SIGNALDASH_TRANSCRIBE_GEMINI_MAX_BYTES`,
|
|
387
|
+
`SIGNALDASH_TRANSCRIBE_GEMINI_MAX_OUTPUT_TOKENS`, or
|
|
388
|
+
`SIGNALDASH_ATTACHMENT_MAX_BYTES`.
|
|
389
|
+
|
|
390
|
+
The Gemini backend authenticates with the credentials already on the host: the
|
|
391
|
+
audio never leaves it with a SignalDash key attached, and the transcriber
|
|
392
|
+
resolves those credentials itself, in the child process, from the file named by
|
|
393
|
+
`SIGNALDASH_TRANSCRIBE_AWS_ENV_FILE`. The gateway process forwards only
|
|
394
|
+
configuration, never a secret. That backend reports `language` as `null`: it is
|
|
395
|
+
asked for the transcript text and nothing else.
|