@floomhq/signaldash 0.22.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: signaldash
3
- description: Operate the user's SignalDash connection to LinkedIn, WhatsApp, and email. Use this skill whenever the user says or implies "check my LinkedIn", "who messaged me", "triage my inbox", "read the thread with X", "reply to X", "send this message", "send a connection request", "create an invitation batch", "check invitations", "accept this invitation", "auto-accept my invitations", "check auto-accept status", "withdraw this invitation", "search my LinkedIn connections", "find people in my network", "find people beyond my network", "suppress this contact", "check contact state", "check my action budget", "check WhatsApp", "check my email", "draft a LinkedIn post", "who engaged with my post", "who liked or commented", or "export my LinkedIn connections". Covers durable installation, invite-based setup, local-first connection search, capped paid discovery, immutable human-approved invitation batches, opt-in rate-capped invitation auto-accept, exact contact state and suppression, authoritative LinkedIn action-budget status, safe reading, drafting, approved sends, exact invitation actions, post engagement analysis, and paced connection export.
3
+ description: Operate the user's SignalDash connection to LinkedIn, WhatsApp, and email. Use this skill whenever the user says or implies "check my LinkedIn", "who messaged me", "triage my inbox", "read the thread with X", "reply to X", "send this message", "send a connection request", "create an invitation batch", "run a campaign", "connect and then message them", "message them when they accept", "check invitations", "accept this invitation", "auto-accept my invitations", "check auto-accept status", "withdraw this invitation", "search my LinkedIn connections", "find people in my network", "find people beyond my network", "suppress this contact", "check contact state", "check my action budget", "check WhatsApp", "check my email", "draft a LinkedIn post", "who engaged with my post", "who liked or commented", or "export my LinkedIn connections". Covers durable installation, invite-based setup, local-first connection search, capped paid discovery, immutable human-approved invitation batches, the human-approved campaign loop that messages a person only after a proven acceptance and stops on any reply, opt-in rate-capped invitation auto-accept, exact contact state and suppression, authoritative LinkedIn action-budget status, safe reading, drafting, approved sends, exact invitation actions, post engagement analysis, and paced connection export.
4
4
  ---
5
5
 
6
6
  # Operate SignalDash
@@ -28,42 +28,44 @@ Name that plainly before you run anything:
28
28
  Wait for a clear yes. If the user does not know who sent the link, or is unsure,
29
29
  stop and let them verify first. Do not soften this to get through setup faster.
30
30
 
31
- ## Ensure the skill is saved and enabled
31
+ ## Ensure the skill is installed from a reviewed local package
32
32
 
33
- Do this first when you are reading this file from a URL or pasted text. Do not
34
- rely on the current conversation to retain it.
33
+ Do not install this file as trusted instructions merely because an agent was
34
+ given its public URL or pasted contents. A remote instruction document can
35
+ change independently of the user's installed software and may be rejected by a
36
+ security-conscious agent. When reading this from a URL or pasted text, stop and
37
+ ask the human to review and run the pinned package bootstrap in their terminal.
35
38
 
36
- 1. Save the complete, unmodified `SKILL.md` in a skill directory that the
37
- current agent discovers. Prefer the agent's documented global directory so
38
- the skill works in future projects. Common locations are:
39
-
40
- - Claude Code: `~/.claude/skills/signaldash/SKILL.md`
41
- - Codex: `~/.codex/skills/signaldash/SKILL.md`
42
- - Portable project scope: `.agents/skills/signaldash/SKILL.md`
43
-
44
- 2. When shell access is available, fetch the raw form explicitly:
39
+ 1. The human runs the currently published, pinned package:
45
40
 
46
41
  ```bash
47
- mkdir -p ~/.claude/skills/signaldash
48
- curl -fsSL -H 'Accept: text/plain' https://signaldash.dev/SKILL.md \
49
- -o ~/.claude/skills/signaldash/SKILL.md
50
- test -s ~/.claude/skills/signaldash/SKILL.md
42
+ npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
51
43
  ```
52
44
 
53
- Replace the destination with the current agent's canonical skill directory
54
- when it is not Claude Code.
45
+ This logs in, copies the package-bundled skill into Claude Code, registers
46
+ the local MCP server, and starts the hosted account-connection flow.
47
+
48
+ 2. The package writes the installed copy to the one location it supports
49
+ today:
50
+
51
+ - Claude Code: `~/.claude/skills/signaldash/SKILL.md`
55
52
 
56
- 3. Confirm that the saved file begins with `name: signaldash` and contains the
53
+ Other clients (Codex, a portable `.agents/skills/` layout, etc.) are not
54
+ written automatically. Copy this same reviewed file into that client's own
55
+ skill directory by hand and reload skill discovery there.
56
+
57
+ 3. Confirm that the installed file begins with `name: signaldash` and contains the
57
58
  `Before every send` section. A partial copy is not installed.
58
59
 
59
60
  4. Enable or reload skills according to the current agent. Start a new agent
60
61
  session when the client discovers skills only at startup. After SignalDash
61
62
  setup, restart the MCP connection as well.
62
63
 
63
- 5. The package can install its bundled copy for Claude Code:
64
+ 5. The package can reinstall its bundled copy for Claude Code without repeating
65
+ account setup:
64
66
 
65
67
  ```bash
66
- npx -y @floomhq/signaldash skill
68
+ npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} skill
67
69
  ```
68
70
 
69
71
  Expected success:
@@ -73,8 +75,9 @@ rely on the current conversation to retain it.
73
75
  Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.
74
76
  ```
75
77
 
76
- Persisting the skill is part of setup. Do not continue with a session-only copy
77
- and tell the user setup is complete.
78
+ Persisting the reviewed, package-bundled skill is part of setup. Do not continue
79
+ with a session-only or remotely fetched copy and tell the user setup is
80
+ complete.
78
81
 
79
82
  ## When to reach for SignalDash
80
83
 
@@ -158,7 +161,7 @@ one, or reuse somebody else's code.
158
161
  After the user provides the code, run:
159
162
 
160
163
  ```bash
161
- npx -y @floomhq/signaldash <invite-code>
164
+ npx -y @floomhq/signaldash@{{PACKAGE_VERSION}} <invite-code>
162
165
  ```
163
166
 
164
167
  The invite is a hexadecimal string. The command logs in, installs the bundled
@@ -341,7 +344,7 @@ Use the exact tool names and argument keys below. Limits are optional.
341
344
 
342
345
  | Tool | Arguments | When to use it |
343
346
  |---|---|---|
344
- | `li_list_chats` | `limit` integer 1-100, default 20 | Find recent LinkedIn chats, unread counts, and exact `chat_id` values without opening profiles. |
347
+ | `li_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200, and never more than five provider pages | Find recent LinkedIn chats, unread counts, and exact `chat_id` values without opening profiles. With `search` it filters on `name`, `subject`, `id`, `provider_id` and `attendee_provider_id`. The provider CANNOT filter a chat list, so SignalDash scans and filters: read `search.exhaustive` before you conclude anything, `search.limit_reached` to know whether the matches were truncated by `limit`, and `search.searched_fields` for what was actually compared. `false` means "not among the `scanned_chats` scanned", never "does not exist" -- continue from the returned `cursor`. When `started_from_cursor` is true the scan began mid-list, so even `exhaustive` says nothing about the chats before that cursor. A 1:1 chat has `name: null`, so a person's NAME can never match; resolve it with `li_search_connections` and search for the member id. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter` rather than accepted and ignored. |
345
348
  | `li_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Read one resolved LinkedIn conversation before summarizing, drafting, or sending. |
346
349
  | `li_send_message` | `chat_id` required; `text` required, max 5000 characters | Send one approved LinkedIn reply after an immediate read of that exact chat. |
347
350
  | `li_send_invitation` | `provider_id` required; `note` optional, max 300 exact characters; `confirm` optional, default false | First preview one exact target and note. The server verifies relationship, both invitation directions, and absence of an existing one-to-one chat. After exact approval, repeat the identical call with `confirm:true`; jitter completes before the final preflight and action reservation. |
@@ -354,21 +357,41 @@ Use the exact tool names and argument keys below. Limits are optional.
354
357
  | `sd_settings_get` | no arguments | Read the authenticated user's persistent SignalDash settings. Auto-accept is the first supported setting and is false for every existing user until explicitly changed. |
355
358
  | `sd_settings_set` | `auto_accept_linkedin` required boolean; `auto_accept_linkedin_filters` optional object with `public_identifiers` and `description_keywords`; `confirm:true` required | Update the general per-user settings surface after exact human approval. This changes no future or unknown setting implicitly. |
356
359
  | `sd_auto_accept_status` | no arguments | Inspect whether auto-accept is enabled, its exact filters, accepted today and this week, failed attempts today, dedicated and shared capacity, repeated-error stop state, and sanitized unparseable invitation records. |
360
+ | `sd_voice_profile` | `channel` required, one of `linkedin`, `whatsapp`, `email`; `force_recompute` optional boolean, default false | Build or fetch this user's personal voice profile for exactly one channel, mined only from up to ~50 of their own SENT messages on that channel (never the counterpart's text, never another user's data). Returns compact markdown: hard length stats (median/p75/p90), 5-10 verbatim redacted exemplars, negative constraints, and language behavior. Computed once and cached server-side; only pass `force_recompute:true` when the user explicitly asks to refresh it. |
357
361
  | `li_search_connections` | `query` required string, max 200; `filters` optional object with `company`, `headline_keyword`, `connected_after`, and `connected_before`; `limit` integer 1-100, default 20 | Search only the authenticated user's stored LinkedIn connection snapshot and join exact local contact state. This makes no LinkedIn, Unipile, HarvestAPI, or other paid discovery call. |
358
362
  | `li_discover_people` | `query` required role/title string, max 200; `filters` optional object with `company` and `location`; `limit` integer 1-10, default 10; `confirm` optional, default false | After a completed local snapshot returns no matches, preview one paid HarvestAPI profile-search page. Obtain approval for the exact query and maximum reserved cost, then repeat with `confirm:true`. |
359
363
  | `li_create_invitation_batch` | `source_label` required exact string, max 120 code points; `time_zone` required IANA timezone; `targets` required array of 1-10 exact `{profile_url,inclusion_reason,note?}` objects; reason max 240 and note max 200 Unicode code points | Create one immutable durable preview from canonical LinkedIn Classic `/in/` URLs. The agent structures an explicit human request; the server never generates targets or text. |
360
364
  | `li_get_invitation_batch` | `batch_id` required | Inspect every stored target, exact note, exclusion, hash, timing, capacity, and result. Use the returned browser review path for human approval. This read also authorizes a later exact cancel. |
361
365
  | `li_cancel_invitation_batch` | `batch_id` required; `approval_view_hash` required string or null exactly as inspected; `confirm:true` required | Permanently cancel unstarted targets in one freshly inspected batch. It cannot recall an executing invitation, and restarting requires a new preview and approval. |
362
- | `wa_list_chats` | `limit` integer 1-100, default 20 | Find an existing WhatsApp conversation and exact `chat_id`. |
366
+ | `sd_campaign_create` | `source_label` required, max 120 code points; `time_zone` required IANA timezone; `messages` required array of 1-5 exact `{text,after_days?}` objects, text max 1200 code points; `target_source` optional (`explicit` default or `post_engagers`); `targets` array of up to 150 `{profile_url?,provider_id?,inclusion_reason,note?,display_name?,headline?,adopt_existing_invitation?}` for `explicit` (at most 10 adopted targets per campaign, and an adopted target may not carry a `note`); `engagers` object with `post_limit` 1-10, `max_targets` 1-150, `inclusion_reason`, `note` for `post_engagers`; `invite_ttl_days` optional 1-60, default 21 | Create one campaign: a connection request to each exact person, then the exact approved message(s) once that person is proven to have accepted, then an optional follow-up that stops on any reply. The server never generates a target or a word of text. Nothing is sent before human approval. |
367
+ | `sd_campaign_preview` | `campaign_id` required | Inspect every exact recipient, every exclusion and reason, the exact text of every message step, the timing, the shared daily budget, and the `approval_url` to hand the human. This read also authorizes a later exact cancel. |
368
+ | `sd_campaign_approve` | `campaign_id` required; `confirm_token` required in the exact `sd-xxxx-xxxx-xxxx` form the human read off the approval page | Record the human's approval using the single-use code that authenticated page minted for them. An agent cannot mint, guess, or bypass that code. |
369
+ | `sd_campaign_status` | `campaign_id` required | Monitor per person: invited, acceptance proven, messaged, replied and halted, expired, or excluded, plus every message step and any provider response SignalDash could not parse. |
370
+ | `sd_campaign_cancel` | `campaign_id` required; `approval_view_hash` required string or null exactly as inspected; `confirm:true` required | Revoke the approval and stop every unsent invitation and unsent message. It cannot recall an executing write, and restarting requires a new preview and approval. |
371
+ | `sd_withdrawal_batch_create` | `account` required, exactly `linkedin`; `exclude` REQUIRED array of up to 100 exact `{kind,value}` objects, kind one of `invitation_id`, `provider_id`, `public_identifier`, `profile_url`, `member_urn`, `display_name`; `time_zone` required IANA timezone; `older_than_days` optional integer, default 90, minimum 30; `limit` optional 1-1500; `source_label` optional, max 120 code points; `allow_unmatched_exclusions` optional boolean | Freeze the exact PENDING SENT invitations older than the age rule, for one human approval and a paced sweep. `exclude` is required, not optional: ask the human who must NOT be withdrawn before you call this. An exclusion that matches nobody fails the preview by default, because that is what a typo looks like. |
372
+ | `sd_withdrawal_batch_status` | `withdrawal_batch_id` required | Inspect or monitor one sweep: done and remaining, the per-day pace and this sweep's own daily allowance, the stop reason, parse failures, the exact people the exclusions protected, and the `approval_url` to hand the human. This read also authorizes a later exact cancel. |
373
+ | `sd_withdrawal_batch_approve` | `withdrawal_batch_id` required; `confirm_token` required in the exact `sd-xxxx-xxxx-xxxx` form the human read off the approval page | Record the human's approval using the single-use code that authenticated page minted for them. An agent cannot mint, guess, or bypass that code. |
374
+ | `sd_withdrawal_batch_cancel` | `withdrawal_batch_id` required; `approval_view_hash` required string or null exactly as inspected; `confirm:true` required | Revoke the approval and stop every withdrawal that has not started. It cannot recall one already submitted, and it cannot restore the three-week re-invite block for people already withdrawn. |
375
+ | `wa_list_chats` | `limit` integer 1-100, default 20; `cursor` optional; `search` optional string, max 200 characters; `max_scan` optional integer 1-500, default 200, and never more than five provider pages | Find an existing WhatsApp conversation and exact `chat_id`. With `search` it filters on `name`, `subject`, `id`, `provider_id` and `attendee_public_identifier`, which carries the phone number, and it normalizes digits so `+49 151 67609512`, `004915167609512` and `4915167609512@s.whatsapp.net` all match. The provider CANNOT filter a chat list, so SignalDash scans and filters: read `search.exhaustive` before you conclude anything, `search.limit_reached` to know whether the matches were truncated by `limit`, and `search.searched_fields` for what was actually compared. `false` means "not among the `scanned_chats` scanned", never "does not exist" -- continue from the returned `cursor`. When `started_from_cursor` is true the scan began mid-list, so even `exhaustive` says nothing about the chats before that cursor. A 1:1 chat has `name: null`, so search the phone number, not the person's name. Any other key, including `text` and `member_id`, is refused with `unsupported_parameter`. |
363
376
  | `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. |
364
- | `wa_send_message` | `chat_id` required; `text` required, max 5000 characters | Send one approved reply in an existing WhatsApp conversation after an immediate re-read. |
365
- | `email_list` | `limit` integer 1-100, default 20 | List the newest message in each recent email thread and obtain `thread_id`. |
366
- | `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. |
367
- | `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998; `body` required, max 5000 | Send one approved email to a participant in a recently read existing thread. |
377
+ | `wa_get_attachment` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each | Download one attachment of one message in a chat this account owns. Read the chat first: the exact `message_id` and `attachment_id` come from `wa_read_messages`. Returns the stored path on the SignalDash host, mimetype, byte size and sha256. |
378
+ | `wa_transcribe_voice` | `chat_id`, `message_id`, `attachment_id` all required, max 500 characters each; `backend` optional, exactly `gemini` or `whisper` | Turn one WhatsApp voice note into text through SignalDash instead of fetching provider bytes yourself. Always read the returned `backend`: `gemini` is the accurate default, `whisper-small` is the weak local fallback and mangles German with English terms mixed in, and a fallback also carries `fallback_reason`. Non-audio attachments are refused with `415 not_audio`; an unknown backend with `400 unknown_backend`; a transcription that exceeds its time limit returns `504 transcription_timeout` with the stored audio path. |
379
+ | `wa_send_message` | `chat_id` required; `text` optional only when a file is attached, max 5000 characters; `attachments` optional array of up to 4 exact `{filename, content_type, content_base64}` files, at most 16 MiB per file and 16 MiB per message, types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `application/pdf`, `text/csv`, `text/plain`, `application/json`, `application/zip`, xlsx | Send one approved reply, one approved file, or both, in an existing WhatsApp conversation after an immediate re-read. A file spends the same daily send budget and is recorded the same way as a text message; there is no separate attachment budget. A call carrying neither text nor an attachment is refused with `400 text_or_attachment_required`. Attachments are checked before anything is reserved, so a refusal costs no send: `400 unsupported_attachment_type`, `400 attachment_too_large`, `400 attachments_too_large`, `400 too_many_attachments`, `400 malformed_attachment_base64`, `400 invalid_attachment_filename`, and `413 request_too_large` when the whole body is too big to read. Nothing is ever truncated or dropped silently. The same caption with the same file is refused as `409 duplicate_send`; the same caption with a different file is a different message and goes through. If a send times out or the provider never confirms it, the message may still have been delivered: an identical retry is refused with `409 send_outcome_unknown`. Read the chat AGAIN, and ONLY if the message is genuinely absent, resend the identical payload with `confirm_resend:true`. The re-read is enforced, not advisory: a `confirm_resend` whose most recent read of that chat predates the failed attempt is refused with `428 reread_after_failed_send_required`, because a read taken before the attempt cannot show whether the message arrived. The send also re-reads the thread immediately before sending and refuses with `409 thread_changed` if the conversation moved after the read that authorized it, or with `502 thread_preflight_unavailable` if that re-read fails; in both cases nothing was sent and no budget was spent. A chat whose most recent read predates thread-change detection is refused with `428 read_before_send_required` until it is read again. LinkedIn messages carry text only. |
380
+ | `wa_delete_message` | `chat_id`, `message_id` both required, max 500 characters each | Retract one message THIS account sent, in a chat this account owns. The exact `message_id` comes from `wa_read_messages`. Irreversible and never retried: someone else's message is refused with `403 message_not_own`, a message outside this chat with `403 message_forbidden`, and a delete already recorded for this exact chat and message with `409 duplicate_delete`. Deletes spend their own daily budget, so `429 rate_limit_exceeded` here never means you are out of sends. WhatsApp applies its own time and role limits to deleting for everyone and can answer successfully without removing anything, so read the chat again to confirm the message is gone. |
381
+ | `wa_delete_messages` | `messages` required array of 1-200 exact `{chat_id, message_id}` objects | Retract several messages this account sent. Same ownership, budget and audit path as `wa_delete_message`, executed strictly one at a time with a pause between them, never in parallel. Always read the per-entry `ok`, `code` and `error`: a partial result is normal. Entries the batch never reached before its time limit come back with `skipped:true` and `code:batch_deadline`, and were not attempted; resend exactly those to resume. |
382
+ | `email_list` | `limit` integer 1-100, default 20; `cursor` optional, max 4096 | List the newest message in each recent email thread and obtain `thread_id`. The response carries a `cursor`; pass it back to read the next page, and omit it for the first. |
383
+ | `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. Everyone on `cc` counts as a participant, so a read authorizes them too. The newest messages are returned, not the oldest, so the people being replied to are always in the window. |
384
+ | `email_send` | `to` required as an array of exactly one valid address; `subject` required, max 998, single line; `body` required, max 5000; `thread_id` optional, max 500 | Send one approved email to a participant in a recently read existing thread. Pass the `thread_id` you read to reply inside that thread; omit it only when starting a new one. A blank or oversized `thread_id` is refused with `400 invalid_request` rather than quietly starting a new thread beside the original. The same subject and body to the same person in the same thread is refused as `409 duplicate_send`; the same words in a different thread are a different message and go through. A subject containing a line break is refused with `400 invalid_request`, because a subject is one header line; a body with line breaks is normal and sends. If a send leaves this host and the provider never answers, the retry is refused as `409 send_outcome_unknown`: read the thread again, and only if the email is genuinely absent resend with `confirm_resend: true`. |
368
385
  | `li_my_posts` | `limit` default 10, max 50; `member_id` optional | Find the user's latest posts and post IDs. Omit `member_id` to use the connected user's own ID. |
369
386
  | `li_post_reactions` | `post_id` required; `limit` default 50, max 100 | Identify who reacted to one post and assess warm signals. A reaction does not authorize outreach. |
370
387
  | `li_post_comments` | `post_id` required; `limit` default 50, max 100 | Read comments and authors for one post; prioritize questions and substantive responses. |
371
- | `li_draft_post` | `text` required, max 3000; `publish` optional, default false | Create a server-confirmed draft. Use `publish:true` only after explicit approval of the final public text. |
388
+ | `li_draft_post` | `text` required, max 3000; `publish` optional; `scheduled_at` optional offset-qualified ISO date-time; `mentions` optional array of up to 20 exact `{name,profile_id}` objects; `attachments` optional array of up to 4 exact `{filename,content_type,content_base64}` images; `first_comment` optional, max 1250 | Create a server-confirmed draft, publish now, or persist an exact future LinkedIn post and its approved first comment. A schedule is stored only with `publish:true` after approval of the exact text, time, mentions, images, and comment. |
389
+ | `li_set_scheduled_post_first_comment` | `id` required UUID; `first_comment` required, max 1250; `confirm:true` required | Attach one exact approved first comment to a scheduled post. SignalDash publishes it through the same connected account after the post and never republishes the post if the comment fails. |
390
+ | `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
391
+ | `li_cancel_scheduled_post` | `id` required UUID; `confirm:true` required | Cancel one exact post while it is still scheduled. It cannot recall an executing or published post. |
392
+ | `sd_schedule_message` | `channel` required, `whatsapp` or `linkedin`; `chat_id` required, max 500; `text` required, max 5000; `scheduled_at` required offset-qualified ISO date-time from 60 seconds to 365 days ahead; `confirm:true` required | Schedule one exact message into one chat you have already read. Read that exact chat first, at `limit` 10 or more on LinkedIn: SignalDash records what the thread looked like and refuses at send time if the conversation moved. Text only; attachments are refused rather than dropped. One message at one time, never a sequence. |
393
+ | `sd_scheduled_messages` | `state` optional, one of `scheduled`, `executing`, `sent`, `cancelled`, `failed`, `needs_review`; `channel` optional | List only this authenticated user's scheduled messages and their durable states. Returns every matching row, unpaginated, and always reports `needs_review_count` outside your filter. |
394
+ | `sd_cancel_scheduled_message` | `id` required UUID; `confirm:true` required | Cancel one exact message while its state is still `scheduled`. It cannot stop one already being sent, and it cannot unsend one that has been sent. |
372
395
 
373
396
  Representative calls:
374
397
 
@@ -388,22 +411,42 @@ sd_budget_status({})
388
411
  sd_settings_get({})
389
412
  sd_settings_set({"auto_accept_linkedin":true,"auto_accept_linkedin_filters":{"public_identifiers":["amina-rahman"],"description_keywords":["Founder"]},"confirm":true})
390
413
  sd_auto_accept_status({})
414
+ sd_voice_profile({"channel":"whatsapp"})
415
+ sd_voice_profile({"channel":"linkedin","force_recompute":true})
391
416
  li_search_connections({"query":"founder agents","filters":{"company":"Acme","headline_keyword":"Founder","connected_after":"2025-01-01","connected_before":"2026-01-01"},"limit":20})
392
417
  li_discover_people({"query":"Platform Engineer","filters":{"company":"Acme","location":"Berlin"},"limit":5})
393
418
  li_discover_people({"query":"Platform Engineer","filters":{"company":"Acme","location":"Berlin"},"limit":5,"confirm":true})
394
419
  li_create_invitation_batch({"source_label":"Approved Berlin founder shortlist","time_zone":"Europe/Berlin","targets":[{"profile_url":"https://www.linkedin.com/in/amina-rahman/","inclusion_reason":"Named by the user for this exact batch","note":"Hi Amina, I enjoyed your post on agent safety."}]})
395
420
  li_get_invitation_batch({"batch_id":"00000000-0000-4000-8000-000000000000"})
396
421
  li_cancel_invitation_batch({"batch_id":"00000000-0000-4000-8000-000000000000","approval_view_hash":"exact-64-character-hash-from-the-read","confirm":true})
422
+ sd_campaign_create({"source_label":"Follow up on invitations I already sent","time_zone":"Europe/Berlin","targets":[{"profile_url":"https://www.linkedin.com/in/amina-rahman/","inclusion_reason":"I sent this connection request by hand last week","adopt_existing_invitation":true}],"messages":[{"text":"Thanks for connecting, Amina."}]})
423
+ sd_campaign_create({"source_label":"People who engaged with my launch post","time_zone":"Europe/Berlin","target_source":"post_engagers","engagers":{"post_limit":3,"max_targets":40,"inclusion_reason":"Reacted to or commented on my post"},"messages":[{"text":"Thanks for connecting, Amina."},{"text":"I build the guardrails layer for agent sends."},{"text":"Worth a short call?","after_days":4}]})
424
+ sd_campaign_preview({"campaign_id":"00000000-0000-4000-8000-000000000000"})
425
+ sd_campaign_approve({"campaign_id":"00000000-0000-4000-8000-000000000000","confirm_token":"sd-4f2a-91bc-73de"})
426
+ sd_campaign_status({"campaign_id":"00000000-0000-4000-8000-000000000000"})
427
+ sd_campaign_cancel({"campaign_id":"00000000-0000-4000-8000-000000000000","approval_view_hash":"exact-64-character-hash-from-the-read","confirm":true})
397
428
  wa_list_chats({"limit":20})
398
429
  wa_read_messages({"chat_id":"chat_wa_91b2","limit":20})
430
+ wa_get_attachment({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71","attachment_id":"att_wa_0a33"})
431
+ wa_transcribe_voice({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71","attachment_id":"att_wa_0a33"})
399
432
  wa_send_message({"chat_id":"chat_wa_91b2","text":"16:30 works. See you then."})
433
+ wa_send_message({"chat_id":"chat_wa_91b2","text":"Q3 numbers attached.","attachments":[{"filename":"q3-arr.csv","content_type":"text/csv","content_base64":"<base64>"}]})
434
+ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
435
+ wa_delete_messages({"messages":[{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"},{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c72"}]})
400
436
  email_list({"limit":20})
401
437
  email_read({"thread_id":"thread_email_c402","limit":30})
402
- email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study."})
438
+ email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study.","thread_id":"thread_email_c402"})
403
439
  li_my_posts({"limit":5})
404
440
  li_post_reactions({"post_id":"post_urn_8821","limit":50})
405
441
  li_post_comments({"post_id":"post_urn_8821","limit":50})
406
442
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
443
+ li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
444
+ li_scheduled_posts({})
445
+ li_cancel_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
446
+ sd_schedule_message({"channel":"whatsapp","chat_id":"chat_wa_91b2","text":"Following up on the Q3 numbers, as promised.","scheduled_at":"2026-08-19T09:00:00Z","confirm":true})
447
+ sd_scheduled_messages({})
448
+ sd_scheduled_messages({"state":"needs_review"})
449
+ sd_cancel_scheduled_message({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
407
450
  ```
408
451
 
409
452
  List and read success returns JSON with `items` and often a `cursor`. A message
@@ -420,8 +463,17 @@ result is deterministic:
420
463
  ```
421
464
 
422
465
  Publishing is public and irreversible. Never interpret "draft a post" as
423
- permission to publish. `email_send` cannot start a cold thread and must never be
424
- looped over recipients.
466
+ permission to publish or schedule. For a future post, first call
467
+ `li_draft_post` without `publish:true` and show the exact returned text,
468
+ offset-qualified UTC instant, mentions, attachment count, and first comment.
469
+ After explicit
470
+ approval, repeat the identical payload with `publish:true`. Verify the stored
471
+ record with `li_scheduled_posts`. For an existing scheduled post, use
472
+ `li_set_scheduled_post_first_comment` only after approval of the exact comment.
473
+ SignalDash persists the published post ID before sending the comment, so a
474
+ comment failure never republishes the post. It fails interrupted or ambiguous
475
+ executions closed and never retries them automatically. `email_send` cannot
476
+ start a cold thread and must never be looped over recipients.
425
477
 
426
478
  ### Exact contact state and suppression
427
479
 
@@ -522,12 +574,77 @@ this week, failed attempts today, remaining dedicated and shared capacity,
522
574
  `consecutive_errors`, `disabled_reason`, and every recent unparseable record.
523
575
  Never describe a disabled or erroring worker as active.
524
576
 
525
- ### Campaign time boundary
526
-
527
- SignalDash does not expose a general message scheduler. The first executable
528
- campaign scope has no user-selected future start date, recurring schedule,
529
- automatic follow-up, acceptance-triggered message, or multi-message
530
- ("double text") sequence. Do not claim that any such action was queued.
577
+ ### Scheduling one exact message
578
+
579
+ `sd_schedule_message` holds one exact message for one exact future instant and
580
+ sends it once. It is not a follow-up, a sequence, or a reminder that repeats.
581
+ Schedule a message only when the user asked for that message at that time.
582
+
583
+ A scheduled send is the only write in SignalDash that reaches a person with
584
+ nobody watching, so it is guarded twice: once when you schedule it and once
585
+ again at the moment it fires.
586
+
587
+ At schedule time you must have read the exact chat, on LinkedIn at `limit` 10 or
588
+ more. SignalDash records what that read returned. A message you cannot prove was
589
+ written against a real, recent view of the conversation is refused now rather
590
+ than accepted and quietly parked days later, while there is still a human in the
591
+ room to hear about it. This is why `watermark` and `item_limit` are not
592
+ arguments: they are read off the record of your own read, and a proof a caller
593
+ could supply is not a proof.
594
+
595
+ At fire time SignalDash re-reads the thread and compares it. If the conversation
596
+ moved, the message is not sent. It goes to `needs_review` for a human instead,
597
+ because a message written on Monday against Monday's conversation may be deaf or
598
+ wrong by Thursday, and the person receiving it has no way to know it was written
599
+ before their reply existed. Everything else the live send path checks still
600
+ applies at that moment: chat ownership, contact suppression, the duplicate
601
+ guard, and the daily send budget.
602
+
603
+ `confirm:true` is required, and it means the human approved this exact channel,
604
+ chat, text, and time. `scheduled_at` must carry `Z` or an explicit numeric UTC
605
+ offset. SignalDash does not infer a timezone: it does not know which one you
606
+ meant, and the cost of guessing wrong is a message arriving in the middle of
607
+ somebody's night.
608
+
609
+ Check `sd_scheduled_messages` after scheduling, and read `needs_review_count`
610
+ every time even when you filtered it out. A row in `needs_review` stopped at
611
+ send time and is waiting on a person; its `failure_detail` says what stopped it.
612
+ For some of those rows whether the message reached the other person is genuinely
613
+ unknown, and that is the answer to report, not a guess in either direction.
614
+
615
+ `sd_cancel_scheduled_message` works only while the state is `scheduled`. It
616
+ cannot stop a message that is already being sent, and it cannot unsend one that
617
+ has been sent: that text is in the other person's chat and nothing here takes it
618
+ back. If a sent message was wrong, say so in the chat. A cancel that names an id
619
+ belonging to somebody else is refused in exactly the same words as an id that
620
+ never existed, so a refusal never tells you whether another account's message is
621
+ real.
622
+
623
+ ### Post scheduling and campaign time boundary
624
+
625
+ SignalDash supports an exact one-time future LinkedIn post through
626
+ `li_draft_post`. It does not infer a timezone: `scheduled_at` must contain `Z`
627
+ or an explicit numeric UTC offset. Mention names must appear exactly in the
628
+ approved text. Image bytes are frozen with the schedule, so later file changes
629
+ cannot alter the approved payload. An optional approved `first_comment` is
630
+ frozen with the same schedule and is posted through the same connected account
631
+ after the post provider ID is durably stored. A failed or ambiguous comment is
632
+ never retried and never causes the post to be published again. Use
633
+ `li_scheduled_posts` to verify the durable post and comment states. Cancellation
634
+ requires a fresh list, the exact id, approval, and
635
+ `confirm:true`; it works only while state is `scheduled`.
636
+
637
+ Everything SignalDash can schedule is one exact thing at one exact time:
638
+ `li_draft_post` for a post, `sd_schedule_message` for a message. There is no
639
+ recurring schedule, no automatic follow-up, no acceptance-triggered message and
640
+ no multi-message ("double text") sequence anywhere in this tool set, and the
641
+ first executable campaign scope has no user-selected future start date either.
642
+ Do not claim that any such action was queued, and do not try to assemble one out
643
+ of several scheduled messages into the same chat. That is not only forbidden, it
644
+ does not work: the first message landing is itself a change to the conversation,
645
+ so the second one, written against the thread as it was before, fails its
646
+ fire-time freshness check and parks as `needs_review` for a human. You get one
647
+ message sent and one waiting, which is worse than having scheduled nothing.
531
648
 
532
649
  The common server write authority enforces the design-approved time controls
533
650
  for every campaign action:
@@ -549,7 +666,9 @@ draft, and human approval.
549
666
 
550
667
  ### Human-approved invitation batches
551
668
 
552
- An invitation batch is the only executable campaign form. It contains 1–10
669
+ An invitation batch is the invitation-only form: it stops permanently at the
670
+ invitation result. For the request-then-message loop use the campaign tools in
671
+ the next section. A batch contains 1–10
553
672
  exact canonical LinkedIn Classic profile URLs, one exact inclusion reason per
554
673
  target, and one optional exact invitation note per target. The note limit is
555
674
  200 Unicode code points. Source labels, reasons, and notes reject leading or
@@ -584,9 +703,11 @@ Use the batch tools in this order:
584
703
 
585
704
  Cancel permanently terminates the batch and affects only unstarted targets.
586
705
  It cannot recall an executing provider action. A restart requires a new batch,
587
- preview, and human approval. There is no pause/resume, priority, future start,
588
- recurring schedule, automatic follow-up, acceptance polling, acceptance
589
- message, multi-message sequence, or action after a successful invitation.
706
+ preview, and human approval. A batch itself has no pause/resume, priority,
707
+ future start, recurring schedule, follow-up, acceptance polling, acceptance
708
+ message, or action after a successful invitation. Those belong to the separate
709
+ campaign loop below, which requires its own explicit human approval of both the
710
+ invitation and every message.
590
711
 
591
712
  Unknown outcomes, provider warnings, checkpoints, HTTP 403, and HTTP 429 stop
592
713
  the batch and lock the sender. Two campaign `outcome_unknown` writes on
@@ -598,6 +719,189 @@ Definite provider rejections consume attempts but can leave later exact targets
598
719
  eligible to run. SignalDash limits are not LinkedIn-safe thresholds, and
599
720
  native LinkedIn activity can still race the final reads.
600
721
 
722
+
723
+ ### The human-approved campaign loop
724
+
725
+ A campaign is exactly one thing: a connection request, then the approved
726
+ message once that person accepts. It is the only place SignalDash acts after an
727
+ acceptance, and it acts only on the exact text a human read and approved.
728
+
729
+ What the server proves, and refuses to assume:
730
+
731
+ - A pending invitation that simply disappeared is NOT an acceptance. It may have
732
+ been withdrawn, expired, or declined. A target advances only when a fresh
733
+ profile read, fetched by the STABLE provider id rather than the mutable
734
+ `/in/<slug>` vanity URL, returns BOTH that same provider id and a connected
735
+ network distance. If the profile now resolves to a different member, the
736
+ target is excluded as `target_identity_changed` and is never messaged: a
737
+ vanity URL can be freed and reissued to somebody else. A distance SignalDash
738
+ cannot classify leaves the target waiting and is reported in
739
+ `sd_campaign_status.parse_failures`, never guessed in either direction.
740
+ - The sequence stops permanently the moment that person sends anything back.
741
+ - A first message is refused if the thread already holds an outbound message,
742
+ and any step whose exact text is already in the thread is skipped.
743
+ - A provider list SignalDash cannot recognise is never read as an empty list.
744
+ Every absence proof -- no reply, no prior outbound, no existing conversation,
745
+ no pending invitation -- blocks and is reported rather than passing silently.
746
+ - Campaign invitations and campaign messages consume the SAME per-sender daily
747
+ budget as your manual sends, plus the campaign ceiling and the weekly
748
+ invitation cap. They obey the Monday-Friday 09:00-17:00 sender-local window
749
+ and the 90-180 second pacing interval, and they never burst to catch up.
750
+ - Building a target list from post engagers fetches no profiles: the reactions
751
+ and comments endpoints already return the member id and the network distance.
752
+ Prefer that and `li_search_connections` over anything that opens profiles.
753
+
754
+ Use the campaign tools in this order:
755
+
756
+ 1. Get the exact people and the exact words from the human. Draft the messages
757
+ in the user's own voice and keep them short: the on-acceptance group is two
758
+ or three separate sends, never one block. Never invent a recipient or a
759
+ sentence. Ask whether they already sent any of these people a connection
760
+ request themselves; if so, mark that target `adopt_existing_invitation`
761
+ instead of dropping them.
762
+ 2. Call `sd_campaign_create` once.
763
+ 3. Poll `sd_campaign_preview` until the campaign is `previewed` or terminal.
764
+ Show the human every recipient, every exclusion and its reason, and the exact
765
+ text of every message step.
766
+ 4. Relay the returned `approval_url`. The human authenticates with the
767
+ SignalDash invite credential, reads the rows, ticks the recipients they
768
+ approve (none are preselected), and acknowledges the consequences. They then
769
+ either approve there, or press the button that mints a one-time
770
+ `confirm_token` for you. Only then call `sd_campaign_approve`. An agent
771
+ bearer token cannot approve, and you cannot mint that code. Five failed
772
+ credential attempts durably block that campaign's authentication surface for
773
+ 15 minutes.
774
+ 5. Watch progress with `sd_campaign_status`. Report acceptances, replies,
775
+ exclusions, and parse failures honestly, including people who never accepted.
776
+ 6. To stop, inspect first, show the exact state and `approval_view_hash`, obtain
777
+ cancellation approval, then call `sd_campaign_cancel` once with that hash and
778
+ `confirm:true`.
779
+
780
+ #### The user already sent the invitation by hand
781
+
782
+ The most common real shape is not a cold list. It is: the human sent somebody a
783
+ connection request themselves, from the LinkedIn app, and now wants the
784
+ follow-up automated. A campaign normally REFUSES that person with
785
+ `invitation_already_pending`, because that exclusion exists to stop a duplicate
786
+ invitation.
787
+
788
+ Set `adopt_existing_invitation: true` on that exact target instead of leaving
789
+ them out. It inverts one check and nothing else:
790
+
791
+ - a still-pending sent invitation to that person becomes REQUIRED, not
792
+ disqualifying;
793
+ - SignalDash sends **no invitation at all** for that person, ever. If the
794
+ pending invitation is not there when SignalDash looks, the person is dropped
795
+ with `adopted_invitation_missing`. A missing invitation is never replaced by
796
+ a new one, so this can never become a send the human did not ask for;
797
+ - the invitation is matched to the target by the STABLE PROVIDER ID from a
798
+ fresh profile read, never by the profile URL. LinkedIn frees and reissues
799
+ vanity URLs, so a URL can resolve to a different human;
800
+ - the approval page states, in that person's own row, that no invitation is
801
+ sent, which invitation is being adopted, and how old it is. Say the same
802
+ thing to the human before you send them the link;
803
+ - an adopted target may not carry a `note`, because no invitation goes out and
804
+ a note would only misrepresent what is about to happen;
805
+ - at most 10 adopted targets per campaign: each one costs one profile read in
806
+ the preview to resolve its provider id.
807
+
808
+ Everything after that is identical to any other target. Acceptance is still
809
+ proven only by a fresh profile read showing a connected network distance, the
810
+ messages still need their own approval on the same page, the thread is still
811
+ read before the first message, and anything in that thread the campaign did not
812
+ send halts the sequence permanently. If the recipient answered the invitation
813
+ note without accepting, that conversation already exists and the target is
814
+ dropped with `existing_conversation`: it is a human conversation in progress,
815
+ not a campaign to resume.
816
+
817
+ The acceptance window (`invite_ttl_days`) for an adopted target is counted from
818
+ the moment SignalDash adopts it, not from the hand-sent invitation, whose age
819
+ LinkedIn only reports as a bucket. Tell the human that if it matters.
820
+
821
+ An invitation nobody accepted within `invite_ttl_days` is dropped and never
822
+ messaged. The approval window is derived from the schedule the human approved --
823
+ the acceptance window plus every follow-up wait plus two days -- so no approved
824
+ step can fall outside its own receipt, and a schedule that would not fit inside
825
+ 60 days is refused at creation. Every individual write still re-proves identity,
826
+ relationship, pending state, and conversation absence immediately before it
827
+ happens, and `sd_campaign_status` reports the exact expiry.
828
+
829
+ ### Clearing a backlog of old pending sent invitations
830
+
831
+ A user with hundreds or thousands of pending sent invitations has no supported
832
+ way to clear them one at a time: `li_withdraw_invitation` needs `confirm:true`
833
+ per action, and generating 824 confirmations would defeat that contract rather
834
+ than satisfy it. The withdrawal sweep is the supported path.
835
+
836
+ **Two things you MUST tell the human, because both are LinkedIn's own
837
+ documented behaviour, and getting either wrong is a false claim:**
838
+
839
+ 1. **This does NOT free up sending capacity.** LinkedIn's help page states that
840
+ withdrawing an invitation does not lift an active sending restriction.
841
+ Withdrawal and the weekly invitation limit are decoupled. Never sell this as
842
+ a way to send more. What it does do is clear a stale backlog, and LinkedIn
843
+ does name invitations that are "ignored, left pending, or marked as spam"
844
+ among the things it looks at.
845
+ 2. **After withdrawing, the human cannot re-invite that person for up to three
846
+ weeks.** That cost is per person and irreversible for that window. Approving
847
+ 824 withdrawals accepts it for 824 people. Say the number.
848
+
849
+ Do not repeat the widely-quoted "3000 pending invitations" ceiling. It is in no
850
+ LinkedIn document and vendor numbers contradict each other. Do not suggest that
851
+ invitation behaviour causes hidden reach suppression; LinkedIn's restrictions
852
+ are explicit and notified.
853
+
854
+ **`exclude` is required and load bearing.** Ask the human, before you call
855
+ anything, who must NOT be withdrawn. A real request came with three specific
856
+ people the owner had just protected, and two of the three fell inside his own
857
+ blanket age rule. Passing `[]` is allowed but it means nobody is protected, and
858
+ the review page says so in those words. An exclusion that matches nobody fails
859
+ the preview by default: that is the shape of a typo, and a typo means the
860
+ person they meant to protect ends up in the withdraw list.
861
+
862
+ **Age labels are buckets, not dates.** LinkedIn does not expose a send date. It
863
+ exposes "Sent 4 months ago", and the timestamp is derived from that label, so
864
+ ordering inside a bucket is meaningless and any day-based filter is
865
+ bucket-accurate at best. SignalDash selects an invitation only when its WHOLE
866
+ bucket is older than the threshold. On a real account a naive label filter
867
+ matched 1384 invitations while this conservative rule matched 1036; the 348
868
+ difference is people whose bucket straddles the threshold. They are held back,
869
+ because withdrawing them costs three weeks and cannot be undone.
870
+ `sd_withdrawal_batch_status` reports both counts.
871
+
872
+ Pace and budget:
873
+
874
+ - One withdrawal per tick, 4-12 seconds apart, at most 44 a day across 4 short
875
+ runs of 11 with a 45-minute gap between runs. Never one continuous session.
876
+ The only documented hard failure was roughly 8000 withdrawals in one
877
+ uninterrupted burst producing "Account Network Blocked".
878
+ - Monday-Friday 09:00-17:00 in the sender timezone you supply.
879
+ - The sweep has its OWN daily allowance and never consumes the send budget, so
880
+ it cannot starve real invitations and messages. It is visible in
881
+ `sd_budget_status` under `withdrawal_sweep`, explicitly not counted in
882
+ `safe_actions_remaining_today`.
883
+ - A 403, a 429, any provider warning, an unrecognised provider response, or an
884
+ account status change stops the WHOLE sweep, not just one item. Restarting
885
+ needs a new preview and a new human approval.
886
+ - An invitation that stopped being pending, because the person accepted or it
887
+ expired, is skipped rather than forced, and the sweep continues.
888
+
889
+ Use the tools in this order:
890
+
891
+ 1. Ask the human for the age rule and, explicitly, the people to protect.
892
+ 2. Call `sd_withdrawal_batch_create` once with `exclude` filled in.
893
+ 3. Poll `sd_withdrawal_batch_status` until it is `previewed` or terminal. Show
894
+ the human the count, the protected names, both age counts, the three-week
895
+ consequence, and that this does not free sending capacity.
896
+ 4. Relay the returned `approval_url`. The human authenticates, reads the rows,
897
+ unticks anyone they want kept, and either approves there or presses the
898
+ button that mints a one-time `confirm_token` for you. Only then call
899
+ `sd_withdrawal_batch_approve`. An agent bearer token cannot approve.
900
+ 5. Watch with `sd_withdrawal_batch_status`. Report the stop reason honestly if
901
+ it stopped.
902
+ 6. To stop early, inspect first, then `sd_withdrawal_batch_cancel` with the
903
+ `approval_view_hash` it returned and `confirm:true`.
904
+
601
905
  ### Search your stored LinkedIn network
602
906
 
603
907
  `li_search_connections` is the primary list-building path before any external
@@ -712,6 +1016,29 @@ and the command exits with failure.
712
1016
  These controls run on the server. Prompt instructions, a new session, or a
713
1017
  different calling order cannot bypass them.
714
1018
 
1019
+ ### 403 `channel_required`
1020
+
1021
+ Meaning: this session has not yet completed its own QR scan, so it has no
1022
+ linked channel. An invite code proves an operator sent it to this person. It
1023
+ does not prove the person still controls a channel some earlier session
1024
+ linked, so a session that has not scanned cannot read stored data about the
1025
+ person or about third parties, and cannot change persistent settings. This
1026
+ covers `sd_contact_state`, `li_search_connections`, `sd_settings_set`, and
1027
+ every campaign and withdrawal-batch tool.
1028
+
1029
+ Comply:
1030
+
1031
+ 1. Do not retry the tool and do not switch to another tool to read the same
1032
+ data.
1033
+ 2. Relay the personal setup link to the human and have them finish the QR
1034
+ scan, exactly as in "First-time setup".
1035
+ 3. Confirm with the status check in step 3 of that section, then retry.
1036
+
1037
+ `sd_settings_get`, `sd_budget_status`, `sd_auto_accept_status`, and
1038
+ `sd_voice_profile` are deliberately not behind this guard: the first three are
1039
+ how you verify a live session during setup, and `sd_voice_profile` reports
1040
+ `<channel>_not_connected` instead.
1041
+
715
1042
  ### 428 `read_before_send_required`
716
1043
 
717
1044
  Meaning: this user has not successfully read the exact chat recently, or the
@@ -728,6 +1055,48 @@ Comply:
728
1055
 
729
1056
  Do not satisfy this guard by reading a different chat with a similar name.
730
1057
 
1058
+ A LinkedIn or WhatsApp message send also returns this code when the most recent
1059
+ read of that chat predates thread-change detection and therefore carries no
1060
+ record of what the thread looked like. Read the chat again and send.
1061
+
1062
+ `sd_schedule_message` returns it for the same reason and one more: a LinkedIn
1063
+ read of fewer than 10 messages is not recorded as proof at all, so reading that
1064
+ thread again at the same shallow depth returns this code again. Read at `limit`
1065
+ 10 or more. The refusal names the depth and the window it enforced; read what it
1066
+ says rather than repeating the call. The check runs before the `confirm` gate,
1067
+ so you find out you have to read the chat before you ask anyone to approve a
1068
+ message you cannot yet schedule.
1069
+
1070
+ ### 409 `thread_changed`
1071
+
1072
+ Meaning: the exact chat moved after the read that authorized the send. Both
1073
+ `li_send_message` and `wa_send_message` re-read the thread immediately before
1074
+ sending and refuse unless it is still the thread that was read. A read inside
1075
+ the 30-minute window is not enough on its own: a thread that changed 29 minutes
1076
+ ago satisfies the window and is still not the conversation the draft was
1077
+ written against.
1078
+
1079
+ Comply:
1080
+
1081
+ 1. Do not resend the same text.
1082
+ 2. Re-read the exact chat and read the new messages.
1083
+ 3. Revise the draft against what the thread now says, and obtain approval again.
1084
+ 4. Send once, promptly.
1085
+
1086
+ A `502 thread_preflight_unavailable` on a send means that re-read itself failed,
1087
+ so whether the thread changed is unknown and nothing was sent. Retry the send
1088
+ later; do not treat it as a delivery. It also covers a re-read the provider
1089
+ answered successfully but in a shape SignalDash does not recognise, which is the
1090
+ same fact: no answer about whether the conversation moved. In that case a `428
1091
+ read_before_send_required` on the next attempt is expected too, because a read
1092
+ of an unrecognised shape records no authorization.
1093
+
1094
+ Your own send moves the thread too, so two messages in a row are refused unless
1095
+ you read the chat between them. This is not a glitch to work around: the second
1096
+ message was drafted against a conversation that no longer exists, if only
1097
+ because the first message is now in it. Read, confirm the first message landed
1098
+ as intended, then send the second. If you meant one message, send one message.
1099
+
731
1100
  ### 409 `duplicate_send`
732
1101
 
733
1102
  Meaning: the exact message was already sent to that chat, or the exact email
@@ -764,7 +1133,8 @@ claim that SignalDash has resolved a person across channels.
764
1133
 
765
1134
  ### 409 invitation and context preflight blocks
766
1135
 
767
- `thread_changed`, `already_connected`, `existing_conversation`,
1136
+ `thread_changed` (also returned by a message send, see above),
1137
+ `already_connected`, `existing_conversation`,
768
1138
  `conversation_state_incomplete`, `invitation_already_pending`,
769
1139
  `inbound_invitation_pending`, `invitation_not_pending`,
770
1140
  `invitation_state_incomplete`, and `relationship_unverified` mean the exact
@@ -854,8 +1224,16 @@ public LinkedIn posts.
854
1224
  The hosted backend defaults to 20 LinkedIn action attempts per stable logical
855
1225
  sender per UTC day, shared across messages, invitations, invitation
856
1226
  maintenance, and post publishing. Invitation sends also use the default
857
- 100-attempt weekly policy. Email and WhatsApp retain their hosted send caps. A
858
- deployment can configure different caps. Never promise a particular remaining
1227
+ 100-attempt weekly policy.
1228
+
1229
+ WhatsApp and email share ONE separate daily bucket, and it is independent of
1230
+ the LinkedIn one: a WhatsApp message never spends LinkedIn capacity and a
1231
+ LinkedIn message never spends WhatsApp capacity.
1232
+ Deletes have a third bucket again, so a `429` on a delete never means you are
1233
+ out of sends. Read which cap the `429` names before you conclude anything about
1234
+ another channel. `sd_budget_status` is authoritative for the LinkedIn one.
1235
+
1236
+ A deployment can configure different caps. Never promise a particular remaining
859
1237
  allowance until the response reports `rate_limit.limit` and `remaining`.
860
1238
 
861
1239
  ## Worked flows
@@ -971,6 +1349,30 @@ User: "Find my WhatsApp thread with Sara and reply that 16:30 works."
971
1349
  8. Read the chat again and confirm the text appears once as an outbound
972
1350
  message.
973
1351
 
1352
+ If the user then asks to take that message back:
1353
+
1354
+ 1. Read the chat again and quote back the exact message you are about to
1355
+ remove, with its `message_id`, and get explicit approval for that exact
1356
+ message. A delete cannot be undone and there is no draft state to review.
1357
+ 2. Delete once:
1358
+
1359
+ ```text
1360
+ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
1361
+ ```
1362
+
1363
+ 3. Read the chat again. WhatsApp enforces its own time and role limits and can
1364
+ answer a delete successfully without removing anything, so a 200 is not
1365
+ proof. Only the re-read is.
1366
+ 4. Never delete a message the user did not name. `403 message_not_own` means
1367
+ the message is the other person's and cannot be removed by anyone,
1368
+ including a group admin. `409 duplicate_delete` means SignalDash already
1369
+ recorded this exact delete; do not retry it, re-read instead.
1370
+
1371
+ For several messages, pass them to `wa_delete_messages` in one call rather than
1372
+ looping `wa_delete_message` yourself: it paces them and never runs two at once.
1373
+ Read the per-entry results, and resend only the entries marked
1374
+ `code:batch_deadline`, which were never attempted.
1375
+
974
1376
  ### Flow 4: send, accept, or withdraw one LinkedIn invitation
975
1377
 
976
1378
  For a new invitation:
@@ -1167,6 +1569,74 @@ User: "Invite these two exact LinkedIn profiles with these notes."
1167
1569
  8. Report that planned targets were cancelled and any executing action was not
1168
1570
  recalled. Never claim the batch was paused or can resume.
1169
1571
 
1572
+ ### Flow 9: create, approve, and watch one campaign
1573
+
1574
+ User: "Send connection requests to everyone who engaged with my last post and
1575
+ message them when they accept."
1576
+
1577
+ 1. Confirm the exact source (their own post engagers, or an exact list they
1578
+ supply), the sender timezone, and the exact wording of every message. Draft
1579
+ in their voice, two or three short sends for the on-acceptance group, and
1580
+ read them back for approval before creating anything.
1581
+ 2. Call `sd_campaign_create` once, then poll `sd_campaign_preview` until it is
1582
+ `previewed` or terminal.
1583
+ 3. Report the recipient count, every exclusion reason, and the exact message
1584
+ text. Relay the `approval_url` and do not fetch, submit, or automate that
1585
+ page.
1586
+ 4. If the human hands you a `sd-xxxx-xxxx-xxxx` code, call
1587
+ `sd_campaign_approve` with it once. If they approved in the browser instead,
1588
+ just confirm with `sd_campaign_status`.
1589
+ 5. Report progress from `sd_campaign_status` only. Never treat a disappeared
1590
+ invitation as an acceptance, and never claim a message was sent unless the
1591
+ step state says `sent`.
1592
+ 6. For cancellation, inspect immediately, show the state and
1593
+ `approval_view_hash`, obtain approval, then call `sd_campaign_cancel`.
1594
+
1595
+ ### Flow 10: clear a backlog of old pending sent invitations
1596
+
1597
+ User: "I have 1668 pending invitations. Withdraw everything older than a month."
1598
+
1599
+ 1. Before calling anything, ask two questions and wait for the answers:
1600
+ **"Who must NOT be withdrawn?"** and the sender timezone. Do not guess
1601
+ either. If they say "nobody", that is a real answer and you pass `[]`, but
1602
+ read back that nobody will be protected.
1603
+ 2. Tell them, in plain words, before they approve:
1604
+ - this does **not** free up sending capacity, LinkedIn does not lift a
1605
+ sending restriction when you withdraw;
1606
+ - they will **not be able to re-invite these people for up to three weeks**,
1607
+ and say how many people that is;
1608
+ - "older than a month" is bucket-accurate at best, because LinkedIn only
1609
+ exposes labels like "sent 4 months ago", so the count they see may be
1610
+ smaller than they expect and that is deliberate.
1611
+ 3. Call it once:
1612
+
1613
+ ```text
1614
+ sd_withdrawal_batch_create({
1615
+ "account":"linkedin",
1616
+ "older_than_days":90,
1617
+ "time_zone":"Europe/Berlin",
1618
+ "exclude":[
1619
+ {"kind":"display_name","value":"the exact name they gave you"},
1620
+ {"kind":"public_identifier","value":"their-linkedin-slug"}
1621
+ ]
1622
+ })
1623
+ ```
1624
+
1625
+ 4. Poll `sd_withdrawal_batch_status` until `previewed` or terminal. If it comes
1626
+ back `failed_preview` with `exclusion_matched_nothing`, do NOT retry with
1627
+ `allow_unmatched_exclusions:true` on your own initiative: show the human the
1628
+ unmatched entries and ask whether it is a typo. A typo means the person they
1629
+ meant to protect would be withdrawn.
1630
+ 5. Report the count, the exact protected names, the naive versus conservative
1631
+ age counts, and the expected number of working days at 44 a day. Relay the
1632
+ `approval_url` and do not fetch, submit, or automate that page.
1633
+ 6. If the human hands you a `sd-xxxx-xxxx-xxxx` code, call
1634
+ `sd_withdrawal_batch_approve` with it once. Otherwise confirm with
1635
+ `sd_withdrawal_batch_status`.
1636
+ 7. Report progress from `sd_withdrawal_batch_status` only. If `state` is
1637
+ `stopped`, say the `stop_reason` plainly and do not create a replacement
1638
+ sweep until the human decides.
1639
+
1170
1640
  ## Read-only requests and drafts
1171
1641
 
1172
1642
  Read-only account work does not require send approval. You can list, read,
@@ -1224,8 +1694,9 @@ Before reporting completion, verify:
1224
1694
  - No duplicate, bulk, parallel, warning, 403, or 429 path was bypassed.
1225
1695
  - No sender lock or `outcome_unknown` result was retried.
1226
1696
  - A send was confirmed by a post-send read.
1227
- - A post draft remained unpublished unless `publish:true` was explicitly
1228
- approved.
1697
+ - A post draft remained unpublished and unscheduled unless `publish:true` was
1698
+ explicitly approved. Every scheduled post was read back with its exact UTC
1699
+ instant, mention count, attachment count, first comment, and durable states.
1229
1700
  - An export ended with `+ saved` and a non-empty CSV.
1230
1701
 
1231
1702
  If any item is unverified, state exactly what remains incomplete.
@@ -1248,15 +1719,19 @@ LinkedIn or WhatsApp it gets ignored. Less is more, always.
1248
1719
  fuer). Never translate their language away.
1249
1720
  - **Read the thread first, then sound like the user.** Their own recent messages
1250
1721
  in that thread are the style reference. Copy that register, not a template.
1722
+ - **Call `sd_voice_profile(channel)` before drafting.** It returns this exact
1723
+ user's own hard length stats (median/p75/p90) and verbatim redacted
1724
+ exemplars for that one channel, mined from their real sent messages. Match
1725
+ the returned numbers and rhythm, not a generic idea of "their style" from
1726
+ the thread alone.
1251
1727
 
1252
1728
  Before/after, same intent:
1253
1729
 
1254
- > Bad: "Hi Mircea, I hope you're doing well! Thanks so much for reaching out
1730
+ > Bad: "Hi Alex, I hope you're doing well! Thanks so much for reaching out
1255
1731
  > about scheduling a call. I'd be delighted to connect and would love to explore
1256
1732
  > how we might be able to work together. Please let me know what times work best
1257
1733
  > for you and I'll do my best to accommodate your schedule."
1258
1734
 
1259
- > Good: "hey Mircea, ja gerne. hier mein link: [cal]"
1735
+ > Good: "hey Alex, sure. here's my link: [cal]"
1260
1736
 
1261
1737
  If a draft is longer than the thread's own messages, cut it before showing it.
1262
-