npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 email

Each 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 mcp

Cursor

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 mcp

The 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 directly

call 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_chats
  • li_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_chats
  • wa_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.csv

Safety limits

SignalDash enforces daily write caps. The operating skill adds the human workflow around that runtime control:

  • Every successful LinkedIn write includes aggregate rate_limit data and its applicable lane_rate_limit when classified. rate_limit.limit, used, remaining, and resets_at retain their stable response shape. A lane refusal returns HTTP 429 with an exact invitation, message, or engagement code and rate_limit_lane; the aggregate brake retains code: "rate_limit_exceeded". Every 429 includes Retry-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 429 names 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_read containing 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.service

Then 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.0

release.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/signaldash

Trusted 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.sh

That 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-run

Offline 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>/ \
  --apply

The 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.