@floomhq/signaldash
v0.39.19
Published
Secure LinkedIn, WhatsApp, and email access for AI agents
Readme
SignalDash
Secure LinkedIn, WhatsApp, and email access for AI agents.
@floomhq/signaldash connects your accounts through the hosted SignalDash
service and exposes account-scoped MCP tools. The CLI never receives or stores
the server's Unipile access key.
Quickstart
You need Node.js 20 or newer and a single-use SignalDash invite code. Ask the SignalDash administrator for an invite code, then run:
npx -y @floomhq/signaldash <invite-code>This agent-first setup logs in, installs the bundled operating skill, registers the MCP server with Claude Code when available, and prints hosted-auth links for the human to complete. The equivalent manual sequence is:
npx -y @floomhq/signaldash login <invite-code>
npx -y @floomhq/signaldash connect linkedin
npx -y @floomhq/signaldash connect whatsapp
npx -y @floomhq/signaldash connect emailEach connect command prints a short-lived hosted-auth URL and waits for the
connection to finish. Open the LinkedIn URL and sign in, or open the WhatsApp
URL and scan the live QR code. The email link offers Google, Outlook, and IMAP
when those providers are enabled on the Unipile account. The CLI prints
Connected <provider> when the account is ready.
When the CLI runs without a TTY, it requests a session-bound browser handoff
instead. POST /connect/<provider>/handoff returns a short-lived /c/<token>
link on https://signaldash.dev for the human to open; only the token hash is persisted, and requesting a
new link for the same provider invalidates the previous one. The default
15-minute account-connection lifetime can be configured with
SIGNALDASH_CONNECT_HANDOFF_TTL_MS. Opening the link creates no SignalDash
session and binds the completed provider account only to the bearer session
that requested it.
The invite code is single-use. Login stores a SignalDash user token in
~/.signaldash/config.json; the Unipile access key remains on the SignalDash
server. Set SIGNALDASH_HOME to keep the local config in another directory.
Sessions expire after 90 days by default. Run npx -y @floomhq/signaldash logout
to revoke the current session and remove its local token.
If automatic account detection times out, the CLI prints the manual claim command for the provider:
npx -y @floomhq/signaldash connect linkedin claim <account_id>Add SignalDash to your agent
Claude Code
Paste this command into a terminal:
claude mcp add signaldash -s user -- npx -y @floomhq/signaldash mcpCursor
Create or update .cursor/mcp.json in the project. Merge the signaldash
entry into an existing mcpServers object when the file already contains
other servers:
{
"mcpServers": {
"signaldash": {
"command": "npx",
"args": ["-y", "@floomhq/signaldash", "mcp"]
}
}
}Both registrations start the same stdio server:
npx -y @floomhq/signaldash mcpThe MCP server uses the user token created by login. It cannot access a
LinkedIn, WhatsApp, or email account until that channel has been connected for
the same logged-in user.
Without an MCP client
If the agent session cannot load the MCP tools (server connected, tools not in the roster) the CLI reaches the exact same tool catalog through the exact same backend routes and guards, no MCP transport required:
npx -y @floomhq/signaldash tools # full catalog: name, description, inputSchema
npx -y @floomhq/signaldash call li_list_chats '{"limit":5}' # dispatch one tool directlycall prints one JSON line to stdout and exits 0 on an HTTP 2xx, 1
otherwise (including local argument validation failures, which never reach
the backend). It is not a fallback that skips anything: it is the same
dispatcher the MCP tools/call handler uses, so the write-control ledger,
per-account budgets, read-before-send, pacing, provider-warning lock, and
audit trail all run identically.
Operating skill distribution
The canonical signaldash skill teaches an agent
when to use SignalDash, how to persist the skill, complete setup, interpret
connection state, operate every tool, handle server guards, and run safe worked
flows. The thin secretary skill renders the
stored latest result as one table and has no write authority. The one-command
setup installs both from the same pinned npm package
the human chose to execute:
npx -y @floomhq/[email protected] <invite-code>Run that command in a terminal, not in an agent chat. Do not ask an agent to
fetch a remote SKILL.md and install it as trusted instructions. The public
https://signaldash.dev/SKILL.md endpoint remains available for transparent
human review only.
Other clients can install the reviewed bundled file from the package into their
documented skill directory and reload skill discovery. The narrower
signaldash-safe-usage skill remains
available for clients that separate operating and send-safety instructions.
MCP tools
SignalDash exposes:
sd_version()sd_secretary_latest(limit?, continuation?, ack_token?)sd_secretary_push_set(enabled, confirm)sd_inspiration_list(limit?, status?, content_kind?)sd_inspiration_status(id, status, resulting_post_urn?, confirm)sd_inspiration_import(source, source_author?, federico_note?, purpose?, content_kind?, credit_required?, credit_note?, format_description?)sd_inspiration_channel_configure(chat_id, provider_id, chat_name, confirm)sd_inspiration_channel_set(capture_enabled, confirm)sd_secretary_approve(disposition_id, payload_hash, confirm)sd_secretary_reject(disposition_id, confirm)li_list_chatsli_read_messages(chat_id)li_mark_read(chat_id)li_start_chat(member_ids, text, confirm?, approval_hash?, dry_run?)li_send_message(chat_id?, text?, expected_watermark?, mark_read?, secretary_receipt_id?)li_send_invitation(provider_id, note?, confirm?)li_invitations_received(limit?, cursor?)li_accept_invitation(invitation_id, confirm)li_invitations_sent(limit?, cursor?)li_withdraw_invitation(invitation_id, confirm)sd_contact_state(channel, identifiers, action?, reason?, confirm?)sd_budget_status()sd_settings_get()sd_settings_set(auto_accept_linkedin, auto_accept_linkedin_filters?, confirm)sd_auto_accept_status()sd_voice_profile(channel, force_recompute?)li_search_connections(query, filters?, limit?)li_discover_people(query, filters?, limit?, confirm?)li_create_invitation_batch(source_label, time_zone, targets)li_get_invitation_batch(batch_id)li_cancel_invitation_batch(batch_id, approval_view_hash, confirm)sd_campaign_create(source_label, time_zone, messages?, target_source?, targets?, engagers?, invite_ttl_days?)sd_campaign_preview(campaign_id)sd_campaign_approve(campaign_id, confirm_token)sd_campaign_status(campaign_id)sd_campaign_cancel(campaign_id, approval_view_hash, confirm)sd_withdrawal_batch_create(account, exclude, time_zone, older_than_days?, limit?, source_label?, allow_unmatched_exclusions?)sd_withdrawal_batch_status(withdrawal_batch_id)sd_withdrawal_batch_approve(withdrawal_batch_id, confirm_token)sd_withdrawal_batch_cancel(withdrawal_batch_id, approval_view_hash, confirm)wa_list_chatswa_read_messages(chat_id)wa_mark_read(chat_id)wa_get_attachment(chat_id, message_id, attachment_id)wa_transcribe_voice(chat_id, message_id, attachment_id)wa_start_chat(member_ids, text, confirm?, approval_hash?, dry_run?)wa_send_message(chat_id, text?, attachments?, expected_watermark?, mark_read?)wa_delete_message(chat_id, message_id)wa_delete_messages(messages)email_list(limit)email_read(thread_id, limit)email_send(to, subject, body)li_my_posts(limit, member_id)li_post_reactions(post_id, limit, cursor?)li_post_comments(post_id, comment_id?, resolve_reply_state?, limit, cursor?)li_reply_to_comment(post_id?, parent_comment_id?, trigger_comment_id?, text?, mentions?, expected_watermark?, secretary_receipt_id?)li_like_comment(post_id, parent_comment_id, comment_id, expected_watermark?)li_delete_message(chat_id, message_id, confirm)li_delete_comment(post_id, comment_id, confirm)li_draft_post(text, publish, scheduled_at?, content_pipeline_id?, content_pipeline_override?, mentions?, attachments?, first_comment?)li_set_scheduled_post_first_comment(id, first_comment, confirm)li_save_post_draft(text, mentions?, attachments?, first_comment?, source_key?, metadata?)li_post_drafts()li_update_post_draft(id, text, expected_version, mentions?, attachments?, first_comment?)li_delete_post_draft(id, expected_version, confirm)li_post_draft_preview(id)li_scheduled_posts()li_scheduled_post_preview(id)li_scheduled_post_attachment(id, index)li_edit_scheduled_post(id, text, scheduled_at, mentions?, attachments?, first_comment?, confirm?, approval_hash?, expected_version?, expected_updated_at?)li_cancel_scheduled_post(id, confirm)sd_schedule_message(channel, chat_id, text, scheduled_at, confirm)sd_scheduled_messages(state?, channel?)sd_cancel_scheduled_message(id, confirm)
Every operation runs through the hosted SignalDash backend. Agents never receive the Unipile access key.
When a user's content pipeline is enabled, a future post normally requires its
exact approved content_pipeline_id. A deliberate exception uses
content_pipeline_override:{reason} on the preview call, then repeats the
identical post, time, mentions, image bytes, first comment, and reason with
confirm:true plus the returned single-use approval_hash. The override never
disables the pipeline. Its reason, preview identity, and approval time are
stored atomically with the scheduled post and returned by
li_scheduled_posts. Override-approved payloads are immutable; their first
comment cannot be changed after scheduling.
li_start_chat and wa_start_chat are separate tools because LinkedIn and
WhatsApp expose different stable member-ID formats and spend different budget
lanes. Both use the same safety contract. A first call previews 1 to 10 exact
member IDs and the exact first message. A confirmed call requires the
payload-bound approval hash, resolves every member live, rejects protected or
suppressed contacts, and checks attendee-scoped provider evidence for an exact
existing member set. An existing match is returned without creating a chat or
sending the text. A new chat costs one action, independent of member count,
because the provider performs one irreversible conversation-and-message write.
Success requires readback of exactly one matching chat and exactly one own first
message. dry_run:true repeats the live preflight and stops before the provider
write, without consuming approval or budget. Database capability flags
linkedin_chat_start and whatsapp_chat_start disable the paths immediately.
When WhatsApp resolves an @lid alias to a canonical member ID, the first
preview returns member_alias_requires_exact_id and resolved_member_id.
Preview again with that exact ID before approval. SignalDash never substitutes
an unapproved member identity inside a confirmed write.
The inspiration library also extracts LinkedIn post references already stored
inside conversation signals. This path scans SQLite only and makes zero
LinkedIn or Unipile calls. Feed URLs and post slugs become canonical activity
URNs; lnkd.in is contacted with redirects disabled, and the redirect location
is parsed without requesting linkedin.com. An unresolved short link remains
an offen library row with its raw URL. conversation_sources preserves each
sharing signal, thread, time, direction, and exact or unresolved sharer identity
even when several signals deduplicate to one library URN. The dedicated database
flag can disable extraction without a deployment.
The hourly Secretary push reuses the existing LinkedIn collector cycle and
adds no second inbox poll. It sends only when a new tracked-thread reply,
own-post comment or reaction, or approval-ready draft was stored after push was
enabled. No change means no WhatsApp provider call and no notification. Every
push resolves the exact configured Secretary group from the connected account
and live chat list, reads before sending, and requires an exact one-message
readback. sd_secretary_push_set is the database kill switch; enabling starts
from that instant so old rows do not create a backlog. The self-chat is never a
fallback.
A top-level li_post_comments page reports each comment's reply_counter but
no reply objects, which left "did I already answer this?" unanswerable and kept
the comment loop switched off. resolve_reply_state:true reads each comment's
reply thread and attaches a reply_state. Presence and absence are proved to
different standards on purpose. Seeing an own reply proves replied_by_me:true
whatever the page's completeness, because presence on an incomplete page is
still presence. replied_by_me:false is emitted only when the reply set is
provably whole, every reply carries a resolvable author_details.id, ids are
unique, every reply belongs to this thread, and no reply is the account
owner's. Two proofs are accepted: the provider reporting the reply page
complete, or a zero reply_counter together with a reply read that came back
empty, which is the same pair the reply preflight already requires.
replies_read === reply_counter is deliberately NOT a proof; both numbers are
returned so a caller can see the corroboration, but SignalDash will not convert
it into a boolean, because a wrong false makes an agent talk over the account
owner in public. Everything else is replied_by_me:null with an explicit
reason. The flag is off by default and unresolved comments still carry
reply_state.state = "unknown", so a miss is never mistaken for a proven
absence. Resolution is bounded per request by
SIGNALDASH_REPLY_STATE_MAX_READS (default 25); that bound is not a daily read
cap and does not claim to be one. A provider warning, 403 or 429 stops the
sweep at once and the whole call returns non-2xx carrying the partial evidence,
so a safety condition is never downgraded into a 200.
li_reply_to_comment acts only on an inbound comment on the authenticated
sender's own post. A preceding li_post_comments read records an exact
per-comment watermark. Immediately before the write, SignalDash re-proves the
post owner, sender generation, trigger author, unchanged trigger, parent
thread, absence of an own duplicate, action budget and sender health. A
provider 2xx remains outcome_unknown until bounded readback finds exactly one
own reply with the returned reply ID, expected parent and exact text.
LinkedIn deletion is remediation after exposure, not rollback. It never
relaxes a send or Secretary gate. li_delete_message is limited to the first
60 minutes after the provider send timestamp and proves exact account, chat,
message and own authorship before using a separate remediation budget. It
marks success only after a readback proves the message absent. The v2
li_delete_comment wrapper is installed behind the database capability flag
linkedin_comment_delete, whose migration default is untested. It is not an
available live capability until an exact removable comment on Federico's own
post passes compatibility testing and the flag is separately enabled.
A WhatsApp message can carry files. wa_send_message takes up to 4 exact
{filename, content_type, content_base64} attachments, at most 16 MiB per file
and 16 MiB per message, and accepts PNG, JPEG, WebP, GIF, 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 rather
than sent as an empty message. There is no attachment lane: a send with a file
runs the same ownership check, the same read-before-send guard, the same
duplicate guard and the same daily budget as a text send, and is recorded the
same way. Files are validated before anything is reserved, so a malformed or
oversized attachment costs no send budget and never reaches the provider, and
every refusal names the exact rule and the exact file that tripped it. Nothing
is ever truncated or dropped silently. LinkedIn messages carry text only, and
li_send_message refuses an attachments argument instead of ignoring it.
For a Secretary draft, sd_secretary_approve creates one 15-minute receipt
bound to the exact stored sender generation, recipient, thread, and text.
li_send_message then accepts that receipt ID alone, derives the payload from
storage, refuses caller-supplied overrides, and consumes the receipt atomically
with the action reservation. Rejection records a new human disposition and
sends nothing.
A recent read is no longer sufficient on its own. A message send re-reads the
thread immediately before sending. A counterparty message or a mutation after
the read refuses with 409 thread_changed; an addition consisting only of this
account's own outbound messages does not invalidate the draft. The 409 includes
new_messages, changed_kind, and current_watermark, and both send tools
accept optional expected_watermark to bind a retry to the exact state the
caller reviewed. A 30-minute read window is satisfied just as comfortably by a
conversation that moved 29 minutes ago as by one nobody has touched, and a
reply that arrived in between is precisely when a draft stops being the right
thing to send. A chat whose most recent read predates this guard carries no hash
and is refused with 428 read_before_send_required until it is read again,
rather than waved through on a timestamp that cannot answer the question. If the
re-read itself fails, the send is refused with
502 thread_preflight_unavailable: whether the thread changed is then unknown,
and unknown is not permission. All three refusals happen before anything is
reserved, so none of them costs send budget.
A provider answer this process cannot read counts as a re-read that failed, not
as an empty conversation. Only the two documented list envelopes are hashed;
anything else yields no hash at all, so a read that hits a drifted shape records
no authorization (the next send is refused at 428) and a re-read that hits one
refuses at 502 rather than being compared. That distinction is the whole
guard: hashing an unrecognised answer to the hash of an empty thread would let a
read and a re-read that had both seen nothing agree with each other and satisfy
the requirement that a human looked at the conversation.
A send is not idempotent, so an unconfirmed one is not silently retryable. If
the provider times out, fails with a 5xx, or answers 2xx with a body that will
not parse, the message may well have been delivered: SignalDash records the
attempt before the request leaves and refuses an identical retry 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. That
re-read is enforced rather than merely instructed: a resend whose most recent
read of the chat predates the failed attempt is refused with
428 reread_after_failed_send_required, since a read taken beforehand cannot
show whether the message arrived. Time does not
clear that record, because waiting does not turn a delivered message into an
undelivered one; only the caller confirming what the thread actually shows
does. A provider that answers with a 4xx refused the message outright, so that
record is cleared and the send stays retryable. Two 4xx answers are excepted,
as a judgement call rather than on documented provider semantics: 408 and
429 can both arrive after a message was already accepted and forwarded, so
they are treated as unknown outcomes too.
Override the ceilings with SIGNALDASH_MESSAGE_ATTACHMENT_MAX_BYTES and
SIGNALDASH_MESSAGE_ATTACHMENT_MAX_TOTAL_BYTES; raising either also needs
SIGNALDASH_MESSAGE_SEND_MAX_BODY_BYTES raised to match, because base64 costs a
third on top of the bytes and a body over that cap is refused with
413 request_too_large before it is parsed.
Future LinkedIn posts are persisted by the backend only after the exact text, offset-qualified publish time, mentions, image attachments, and optional first comment have human approval. The stored image bytes and comment text cannot drift after approval. The scheduler publishes the comment through the same connected LinkedIn account immediately after the post. It persists the post ID before attempting the comment, so a comment failure never republishes the post. The scheduler uses the same sender binding, action ledger, duplicate guard, daily budget, and provider-warning lock as immediate publishing. An interrupted or ambiguous execution fails closed and is never retried automatically.
Arbitrary saved LinkedIn drafts use a separate durable store and never enter
the scheduling or publishing worker. li_save_post_draft stores exact text,
up to 25 mentions, up to 20 ordered images, and an optional first comment.
Each image is limited to 5 MiB and one draft to 12 MiB; each tenant is limited
to 1,000 drafts and 100 MiB of decoded attachment bytes. Ordinary duplicate
drafts remain distinct records. Imports may instead supply a tenant-scoped
source_key: an identical replay returns the existing draft, while a replay
with different content is refused. Bounded structured metadata preserves the
source, title, storyline, readiness, hold gates, editorial notes, visual notes,
and revision without placing those fields in the publishable post text. List
responses expose image metadata rather than base64.
li_update_post_draft is a complete compare-and-swap replacement: pass the
current expected_version, and omission clears optional mentions, images, or
first comment. li_delete_post_draft requires the current version and
confirm:true. None of these operations calls LinkedIn, Unipile, Buffer, or an
action-budget lane.
li_post_draft_preview returns a one-use
https://signaldash.dev/d/<token> link for one exact saved draft. POSTing its
confirmation creates a review-duration cookie restricted to /drafts; the page and
its /drafts/:id/attachments/:index media route remain bound to that tenant,
draft, and originating live SignalDash session. Media is served byte-for-byte
with private, no-store, MIME allowlisting, nosniff, and a restrictive CSP.
The shared calendar displays unscheduled SignalDash and Buffer drafts with the
same cards as scheduled posts, marked by a visible DRAFT tag and an
Unscheduled time label. Saved SignalDash media uses separate protected
/calendar/drafts/... URLs. Each saved SignalDash draft also exposes an
authenticated edit-and-feedback form. Its CSRF token is bound to the live
calendar review session, draft UUID, and current version. Copy edits,
feedback, and up to four private feedback images use one atomic
compare-and-swap. Inline saves keep the active filter and scroll position;
the server-rendered fallback returns to the same card. Uploaded images are
stored as private review evidence and served only through the tenant-scoped
calendar session. A reviewer can explicitly replace the current post images
with the same uploads; without that choice, the post media remains unchanged.
Reviews preserve the first comment and append bounded editorial metadata.
Saving non-empty feedback or reference-only feedback images also creates one durable,
tenant- and draft-version-bound
Codex rewrite job in the same SQLite transaction. A separate
signaldash-draft-rewrite.service worker leases those jobs with bounded retries
and timeouts. It runs Codex ephemerally with user configuration, repository
rules, shell, apps, browser, and web search disabled; the child receives only a
sanitized environment and a private temporary directory containing the output
schema and that review's images. Structured output can update only the draft
text and editorial revision state. A newer human edit supersedes stale model
output. An explicit image replacement without feedback keeps the copy unchanged,
while notes, feedback images, mentions, post attachments, first comment,
hold gates, and source metadata remain unchanged after the review transaction.
Cards show queued,
rewriting, ready, failed, or superseded; no rewrite approves, schedules,
publishes, or calls a social provider. Each SignalDash draft card
also names its exact version and the next verified free 08:00 UTC publishing
slot. Approve & schedule re-reads the complete SignalDash and Buffer calendar,
then atomically creates the scheduled post, preserves its copy, mentions,
ordered images, and first comment, records the approval, and removes the source
draft. Stale versions, reused approvals, foreign sessions, incomplete Buffer
reads, and newly occupied days fail closed without calling a provider.
Drafts never enter the items returned by
li_scheduled_posts, preserving its existing calendar contract.
On AX41, install the tracked worker unit without loading sd.env. The worker
runs as federicodeponte. Its privileged ExecStartPre grants that account
write access only to the SQLite database and current WAL/SHM, adds write/search
without directory listing, and retains the sticky bit so it cannot replace
root-owned files. No default ACL is used; the API environment/state files stay
inaccessible via the unit's InaccessiblePaths even if a legacy deployment
has broader mode bits.
The exact-release deployment overrides ExecStart to the merged release path,
then verifies systemd-analyze security, the service log, and /sd_version.
Codex login is maintained in /home/federicodeponte/.codex; deployment enables
the worker only after a real ephemeral read-only Codex sentinel succeeds.
li_scheduled_posts is the shared planning read across posts stored by
SignalDash and the configured Buffer LinkedIn channel. Every item names its
source, and the response reports independent completeness for SignalDash,
Buffer, and native LinkedIn. Unipile has no documented endpoint for posts or
drafts scheduled natively in LinkedIn, so that source is always explicit as
unavailable. SignalDash does not call undocumented Voyager routes or automate
linkedin.com to fill the gap. An empty item list therefore means only that the
visible SignalDash and Buffer sources are empty, never that the native LinkedIn
calendar is empty.
The read-only calendar page renders each stored SignalDash image through a calendar-session-protected, tenant-scoped media route. Buffer image assets and video or document thumbnails use a separate authenticated proxy that accepts only the provider's validated HTTPS media hosts and never exposes a provider URL in browser HTML. Calendar media is no-store and becomes unavailable when the session expires or the originating user is revoked.
li_scheduled_post_preview returns a single-use browser handoff
for one exact active SignalDash post owned by the authenticated user. The page
shows only that post, including its stored image attachments. Preview sessions
and media routes remain account- and post-scoped, expire independently, and
are revoked on logout. New exact-post links use
https://signaldash.dev/p/<token> and calendar links use
https://signaldash.dev/c/<token>. Each new compact token is 8 base64url
characters generated from 6 random bytes. Calendar, scheduled-post preview,
and draft-preview handoffs default to a 60-minute unopened-link and browser-session
lifetime. Pass review_ttl_hours:24 to any of the four review-link tools for an
exact 24-hour handoff and resulting session. Only 1 and 24 are accepted;
the persistence boundary caps both at 24 hours. The default remains configurable
with SIGNALDASH_CALENDAR_HANDOFF_TTL_MS. Their 48 bits of entropy
are bounded by one-use consumption and a durable five-attempt
per-client throttle. Already-issued 24-character compact tokens and 48-character
/scheduled-post/h/<token> or /calendar/h/<token> links remain valid until
their normal expiry.
li_scheduled_post_attachment is the agent-side path to the same stored bytes
the preview page renders, without a browser session: it returns one attachment
of one active post as content_base64 with its filename and its stored
content_type verbatim. It is scoped by the same data_ref and the same
draft/scheduled/executing filter as li_scheduled_post_preview, so a cancelled
or published post, a foreign post, and a missing index are one indistinguishable
404. The bytes are deliberately not part of the preview response, which reports
only attachment_count, because one image is easily hundreds of kilobytes of
base64 and the preview is usually read for the text and the time.
The shared calendar preserves validated Buffer LinkedIn tag ranges and URLs in the post body and also keeps the separate tag audit pills. Valid provider annotations render as clickable LinkedIn links in both places. SignalDash mentions derive inline ranges only when the named occurrence is unique, and derive a LinkedIn company link only from a validated numeric provider ID. Ambiguous names or unsafe URLs remain plain text. Buffer image assets and video/document thumbnails render through the same authenticated, no-store media surface as SignalDash attachments; provider asset URLs are never placed in the browser HTML.
li_edit_scheduled_post replaces one complete scheduled-post payload in place.
The preview call takes the exact UUID, text, offset-qualified time, mentions,
image bytes, and optional first comment. Confirmation repeats that identical
payload with the single-use approval hash and exact source version and
updated_at. One atomic compare-and-swap preserves the UUID, created_at,
provider account, and content-pipeline audit fields. A changed, executing,
published, cancelled, failed, foreign, expired, or replayed target is refused,
and the edit path never contacts LinkedIn or Unipile. Omitting mentions,
attachments, or first_comment in the replacement clears that field.
Scheduled-post duplicate identity covers the complete normalized payload:
text, time, mentions, exact attachment bytes, and first comment. Only active
scheduled or executing rows participate, so a cancelled, published, or
failed historical row never blocks a legitimate new schedule.
sd_schedule_message puts one exact message into one exact chat at one exact
time, on WhatsApp or LinkedIn, text only. It enqueues; it never sends. The
attachment bytes a WhatsApp send accepts are refused here rather than held on
disk for days. The worker sends,
through sendWhatsAppMessage and through the LinkedIn preflight and write path,
so a scheduled message clears every guard a live send clears, including the
daily budget and the duplicate guard, at the moment it fires rather than at the
moment it was written. Scheduling is refused with
428 read_before_send_required unless this session has already read that exact
chat, at limit 10 or more on LinkedIn, because the row has to carry a hash of
the conversation it was written against. That hash is read from the recorded
read and cannot be supplied by the caller: watermark and item_limit are
refused by name rather than accepted and overwritten, since a caller that
believes it set them believes something false. At fire time the worker re-reads
the thread and compares. Anything other than "unchanged" parks the row in
needs_review with a reason and sends nothing, because a conversation that
moved is a question for a human and not a failure of the message.
sd_scheduled_messages lists the caller's own rows with those reasons, and
counts the needs_review ones outside whatever filter was asked for, so a
filtered listing cannot hide a message waiting on a person.
sd_cancel_scheduled_message cancels a row that has not fired.
A row belonging to another account is refused in exactly the same words as an id
that never existed, so the refusal cannot be used to prove somebody else's
message is real. A row that already sent is refused with the plain statement
that cancelling cannot unsend it.
A campaign is one connection request per approved person, then the approved message once a fresh profile read proves that person accepted, then an optional follow-up that stops the moment they reply. A pending invitation that merely disappeared is never treated as an acceptance. Campaign writes consume the same per-sender daily budget as manual sends, obey the Monday-Friday 09:00-17:00 sender-local window and the 90-180 second pacing interval, and cannot start without a human approval recorded on the authenticated approval page. An agent can present that page and carry back the one-time code it mints, but can never approve on its own.
A withdrawal sweep clears a backlog of old pending SENT invitations. It uses the same object as an invitation batch with the arrow reversed: freeze the exact invitation ids now, one human approval bound to the rendered view, then one withdrawal per tick. Two things it will never claim, because both contradict LinkedIn's own documentation: withdrawing does not lift an active sending restriction, so this buys no sending capacity; and after withdrawing, that member cannot be re-invited for up to three weeks. The exclusion list is a required field rather than an option, so protecting nobody is a deliberate choice and not a default. LinkedIn exposes invitation age as a bucket ("sent 4 months ago"), never a date, so the filter selects only when the whole bucket clears the threshold and the review page reports what that conservatism held back. The sweep runs on its own daily allowance and never consumes the send budget, and any 403, 429, provider warning, or account status change stops it entirely.
LinkedIn invitation auto-accept is off for every user until that user
explicitly enables it with confirm:true. The optional filters are an exact
public-identifier allowlist and description-keyword matches; when both groups
are configured, both must match. The single backend worker reads one bounded
received-invitation page, accepts at most five invitations per run and ten per
UTC day by default, jitters every action, and also consumes the invitation
daily lane and aggregate LinkedIn brake. Three consecutive parse or provider
errors disable the setting. sd_auto_accept_status exposes accepted counts
today and this week,
failed attempts today, remaining capacity, the stop reason, and sanitized
records for invitations whose provider shape could not be parsed. Both the current nested
specifics.shared_secret and legacy top-level shape are supported; secrets are
never returned or logged.
li_search_connections is the zero-cost, zero-provider-risk list-building
path. It searches only the authenticated user's stored connection snapshot by
name, headline, or company, supports company/headline-keyword/date filters, and
joins exact local contact state. It never calls LinkedIn, Unipile, or a paid
discovery API. When no snapshot exists, it points to the paced sync below
without starting provider work.
li_discover_people is the capped paid fallback for people beyond a completed
stored network. SignalDash runs the same role/company intent against the local
snapshot first and returns local matches without spending. With no local
matches, the first call returns an exact paid preview. An identical
confirm:true call can make one serialized HarvestAPI profile-search request.
That reservation consumes the preview atomically, so every later paid request
requires a fresh preview and approval.
Durable per-user pacing, per-user and tenant request caps, and a tenant-wide
micro-dollar cap are reserved atomically before the request. Hidden cards,
malformed identities, exact stored connections, and exact contact-state rows
already marked connected are excluded. Results are read-only planning evidence
and cannot become invitation-batch targets automatically.
Invitation batches are the narrow executable campaign boundary. An agent turns the human's request into 1–10 exact canonical LinkedIn Classic profile URLs, inclusion reasons, and optional notes, then creates an immutable durable preview. The agent can inspect or cancel the batch but cannot approve it. A human opens the returned absolute review URL, reauthenticates with the SignalDash invite credential, selects unchecked eligible targets, and approves the exact stored payload for up to 24 hours. Five failed credential attempts block that batch's authentication surface for 15 minutes. The single backend worker rechecks sender binding, exact identity, contact state, prior actions, chats, invitations, daily/weekly budgets, weekday work window, pacing, and the tenant breaker before each at-most-once write. A tenant provider-authentication failure opens the persisted breaker immediately; an open breaker rejects new batches and stops queued preview work before provider reads.
This is not a general campaign engine. It has no pause/resume, priority, future start, recurring schedule, acceptance polling, automatic message, follow-up, discovery-to-send bridge, or runtime target/text generation.
The CLI also provides a paced, resumable LinkedIn connections export:
npx -y @floomhq/signaldash connections linkedin-connections.csvSafety limits
SignalDash enforces daily write caps. The operating skill adds the human workflow around that runtime control:
- Every successful LinkedIn write includes aggregate
rate_limitdata and its applicablelane_rate_limitwhen classified.rate_limit.limit,used,remaining, andresets_atretain their stable response shape. A lane refusal returns HTTP 429 with an exact invitation, message, or engagement code andrate_limit_lane; the aggregate brake retainscode: "rate_limit_exceeded". Every 429 includesRetry-After. Counters are persisted server-side and reset at midnight UTC. - LinkedIn uses three per-sender lanes: 20 invitation actions, 30 messages in existing threads, and 50 replies or reactions on own posts per UTC day by default. Publishing and first comments count with own-post engagement. A separate aggregate cap of 75 is the emergency brake across the lanes. Sent invitations retain the unchanged 100-attempt UTC-week policy.
- WhatsApp and email sends together, and message deletes, retain their own
independent budgets. A
429names the cap it hit; it is never a statement about another lane. Two caveats worth knowing: on the day a deployment first upgrades past the lane split, that day's existing count is still read on the LinkedIn side (it is ambiguous, and the conservative reading is the safe one) and clears at the next UTC midnight; LinkedIn, WhatsApp/email, and delete ledgers are keyed to the stable logical user or sender rather than multiplied by live bearer sessions. - Read the exact thread before every send.
- Preview an exact invitation target and note before sending it.
- List the exact received or sent invitation before accepting or withdrawing it.
- Email sends accept one recipient at a time and require a recent
email_readcontaining that address. - Never infer a recipient from a partial name.
- Never send a duplicate or retry an ambiguous timeout without re-reading.
- Invitation auto-accept remains off until explicitly enabled, consumes both its dedicated daily cap, the invitation lane, and the aggregate LinkedIn brake, and disables itself after three consecutive parse or provider errors.
- Do not parallelize sends or work around a rate limit.
- Do not bypass paid-discovery previews, pacing, request caps, or cost caps.
- Stop on a provider warning, checkpoint, restriction, HTTP 403, or HTTP 429.
- Avoid bulk profile reads, copied message bursts, cold-account automation, and unsolicited WhatsApp messaging.
LinkedIn invitation writes are single-object actions. SignalDash serializes
them with other LinkedIn writes, requires an exact preview or list read,
applies the invitation daily lane and aggregate brake, and enforces a separate
100-attempt weekly policy by default. For a new invitation, jitter completes before the final
preflight and action reservation; that preflight verifies relationship state,
pending invitations in both directions, and absence of an existing one-to-one
chat. Provider timeouts become non-retryable outcome_unknown locks.
The first campaign time boundary is intentionally narrower than scheduled messaging. Campaign invitations require a valid sender IANA timezone, run only Monday through Friday from 09:00 to 17:00 sender-local time, and reserve a new 90–180 second per-sender pacing interval atomically with every attempted action. Timing is checked before final provider preflight and again during the write reservation. Downtime never creates a catch-up burst. A campaign can be connect-only or carry an exact human-approved acceptance-triggered sequence. User-selected future start dates and recurring schedules are not exposed.
Deleting a WhatsApp message is a write, and an irreversible one, so it is
treated as such. SignalDash proves the chat belongs to the connected account,
proves the message sits in that exact chat, and proves the account wrote it,
before anything is removed; someone else's message is refused with 403
message_not_own. Every attempt is appended to that user's own durable audit
trail with the chat, the message, the outcome and the timestamp, including the
attempts that fail and the ones whose outcome the network left unknown. A
delete already recorded for the same chat and message is refused with 409
duplicate_delete rather than replayed. Deletes spend a separate daily budget
(SIGNALDASH_DAILY_DELETE_LIMIT, default 200) so a cleanup can never consume
the sending capacity it exists to repair. wa_delete_messages runs at most 200
entries strictly one at a time, pauses SIGNALDASH_WA_DELETE_PACE_MS between
them, and stops at SIGNALDASH_WA_DELETE_BATCH_DEADLINE_MS, returning the
untouched remainder as skipped with code: batch_deadline so the caller can
resume exactly those. WhatsApp applies its own time and role limits to deleting
for everyone and can answer successfully without removing anything. SignalDash
therefore re-reads the canonical provider message and returns success only when
deleted: 1 or a genuine 404 proves it gone. A present row or failed
readback returns 502 deleted_unconfirmed, records an unknown outcome, and is
not retryable.
Deploying server changes
A server change is not landed when only the files on disk changed. After the
change is merged to main, update the production checkout, restart the
long-running backend, and compare the live MCP result with the checked-out
commit:
git pull --ff-only origin main
git rev-parse HEAD
sudo systemctl restart signaldash-api.service
sudo systemctl is-active signaldash-api.serviceThen call sd_version({}) from an authenticated MCP session. Its commit_sha
must equal git rev-parse HEAD, and its process_started_at must be later than
the restart. The startup journal contains the same snapshot under the
[signaldash] startup prefix. A mismatch means the deployment is stale and the
server change is not shipped. dirty_at_start includes tracked changes,
untracked files, and submodule state present when the process started.
SIGNALDASH_VERSION_ENABLED=0 removes the version route at runtime after a
service restart. It defaults to enabled and exposes no credential, account, or
message data.
Releasing
A release is a tag push. .github/workflows/release.yml runs ./release.sh on
the AX41 self-hosted runner, and that script is the same one a laptop runs, so
there is no second code path to keep honest:
git tag v0.28.0 && git push origin v0.28.0release.sh refuses to publish if the packed files have uncommitted changes or
if the version in package.json is already on the registry, then runs the
syntax check, the tests, npm pack, and the prepublish smoke gate against the
tarball it just built. That gate installs the tarball into a scratch HOME and
drives the real CLI, so a package that unpacks but cannot run never ships. Any
other branch or a workflow_dispatch run does all of that and stops one command
short, at ./release.sh --dry-run.
The credential is the NPM_TOKEN secret, an npm granular access token
scoped to @floomhq/signaldash with write access:
gh secret set NPM_TOKEN -R floomhq/signaldashTrusted publishing (OIDC) is not an option here, so do not spend time on it:
npm does not accept self-hosted runners yet, and this repo is on a self-hosted
runner because GitHub-hosted minutes are unavailable. The workflow asks the
registry directly on every run (scripts/oidc-check.mjs) and prints the answer,
because npm's own OIDC helper is written never to throw and turns a refusal into
an ordinary auth error.
To exercise the publish itself without touching npmjs, run it against a throwaway registry:
scripts/publish-rehearsal.shThat starts verdaccio in a container, mints an account on it, and runs the real
./release.sh with no --dry-run, then prints what the registry received. It
is the only way to test the publish command and the tarball a client actually
downloads before a version number is spent.
Development
npm test
npm run check
npm pack --dry-runOffline LinkedIn archive import
The server-side importer reads LinkedIn export directories or ZIP files without making provider requests. Preview the exact file fingerprints and row counts, then apply the same archive to one bound LinkedIn sender generation:
node scripts/import-linkedin-archive.mjs \
--archive /path/to/Complete_LinkedInDataExport \
--database server/sd-state-control.sqlite3 \
--sender-account <exact-sender-account-id> \
--owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
--dry-run
node scripts/import-linkedin-archive.mjs \
--archive /path/to/Complete_LinkedInDataExport \
--database server/sd-state-control.sqlite3 \
--sender-account <exact-sender-account-id> \
--owner-profile-url https://www.linkedin.com/in/<exact-owner-public-id>/ \
--applyThe owner profile URL is required when messages.csv is present. It is the
typed identity used to classify inbound and outbound rows. A display name is
never used for that decision.
wa_transcribe_voice runs on the SignalDash host and has two backends. The
default, gemini, sends the audio to Vertex AI using that host's own
credentials; it is the one that survives German speech with English technical
terms mixed into it, and being network-bound it does not compete with the
gateway for CPU. The fallback, whisper, needs python3 with faster-whisper
installed on the host and runs the small CPU int8 model, which is much weaker
on that kind of code-switching. Every response therefore names the engine that
produced the text in backend, and a gemini request that had to fall back
also explains why in fallback_reason.
Pick the engine per request with the optional backend argument (gemini or
whisper), or set the default with SIGNALDASH_TRANSCRIBE_BACKEND. Override
the rest with SIGNALDASH_TRANSCRIBE_PYTHON, SIGNALDASH_TRANSCRIBE_MODEL
(local model), SIGNALDASH_TRANSCRIBE_GEMINI_MODEL,
SIGNALDASH_TRANSCRIBE_GEMINI_PROJECT,
SIGNALDASH_TRANSCRIBE_GEMINI_LOCATION,
SIGNALDASH_TRANSCRIBE_GEMINI_TIMEOUT_S (total budget for the Vertex call
including retries, default 240s, and it has to stay under
SIGNALDASH_TRANSCRIBE_TIMEOUT_MS, default 420000, so the local fallback still
fits underneath), SIGNALDASH_TRANSCRIBE_GEMINI_MAX_BYTES,
SIGNALDASH_TRANSCRIBE_GEMINI_MAX_OUTPUT_TOKENS, or
SIGNALDASH_ATTACHMENT_MAX_BYTES.
The Gemini backend authenticates with the credentials already on the host: the
audio never leaves it with a SignalDash key attached, and the transcriber
resolves those credentials itself, in the child process, from the file named by
SIGNALDASH_TRANSCRIBE_AWS_ENV_FILE. The gateway process forwards only
configuration, never a secret. That backend reports language as null: it is
asked for the transcript text and nothing else.
