# 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:

```bash
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:

```bash
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:

```bash
npx -y @floomhq/signaldash connect linkedin claim <account_id>
```

## Add SignalDash to your agent

### Claude Code

Paste this command into a terminal:

```bash
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:

```json
{
  "mcpServers": {
    "signaldash": {
      "command": "npx",
      "args": ["-y", "@floomhq/signaldash", "mcp"]
    }
  }
}
```

Both registrations start the same stdio server:

```bash
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:

```bash
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](skills/signaldash/SKILL.md) 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](skills/secretary/SKILL.md) 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:

```bash
npx -y @floomhq/signaldash@0.39.19 <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](skills/signaldash-safe-usage/SKILL.md) 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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

```bash
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:

```bash
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.
