@floomhq/signaldash 0.13.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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", "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, connection diagnosis, safe reading, drafting, approved sends, 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,42 @@ 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@0.27.0 <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 installed skill lives in a directory that the current agent discovers.
49
+ Common locations are:
55
50
 
56
- 3. Confirm that the saved file begins with `name: signaldash` and contains the
51
+ - Claude Code: `~/.claude/skills/signaldash/SKILL.md`
52
+ - Codex: `~/.codex/skills/signaldash/SKILL.md`
53
+ - Portable project scope: `.agents/skills/signaldash/SKILL.md`
54
+
55
+ 3. Confirm that the installed file begins with `name: signaldash` and contains the
57
56
  `Before every send` section. A partial copy is not installed.
58
57
 
59
58
  4. Enable or reload skills according to the current agent. Start a new agent
60
59
  session when the client discovers skills only at startup. After SignalDash
61
60
  setup, restart the MCP connection as well.
62
61
 
63
- 5. The package can install its bundled copy for Claude Code:
62
+ 5. The package can reinstall its bundled copy for Claude Code without repeating
63
+ account setup:
64
64
 
65
65
  ```bash
66
- npx -y @floomhq/signaldash skill
66
+ npx -y @floomhq/signaldash@0.27.0 skill
67
67
  ```
68
68
 
69
69
  Expected success:
@@ -73,8 +73,9 @@ rely on the current conversation to retain it.
73
73
  Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.
74
74
  ```
75
75
 
76
- Persisting the skill is part of setup. Do not continue with a session-only copy
77
- and tell the user setup is complete.
76
+ Persisting the reviewed, package-bundled skill is part of setup. Do not continue
77
+ with a session-only or remotely fetched copy and tell the user setup is
78
+ complete.
78
79
 
79
80
  ## When to reach for SignalDash
80
81
 
@@ -91,14 +92,41 @@ Typical trigger phrases include:
91
92
  - "Check my email", "summarize the thread", "draft a response", or "send the
92
93
  approved email."
93
94
  - "Draft a post", "turn this into a LinkedIn post", or "publish this post."
95
+ - "Send a connection request to this exact person", "check my invitations",
96
+ "accept this invitation", or "withdraw this pending invitation."
97
+ - "How many LinkedIn actions remain?", "check my action budget", or "can a
98
+ campaign act today?"
99
+ - "Auto-accept my LinkedIn invitations", "turn invitation auto-accept off", or
100
+ "did auto-accept hit any errors?" Use the persistent per-user setting and
101
+ status tools; it is off until explicitly enabled.
102
+ - "Search my LinkedIn connections", "find founders in my network", or "who at
103
+ Acme am I connected to?" Use the local connection snapshot before any
104
+ external or paid discovery.
105
+ - "Find platform engineers beyond my network" or "discover people at Acme in
106
+ Berlin." Use `li_discover_people` only after the completed local snapshot
107
+ returns no matches. Preview the exact paid request and cost before confirming
108
+ one capped provider page.
109
+ - "Schedule this message", "follow up automatically", or "send these as a
110
+ sequence." Explain the narrow campaign time boundary below; do not invent a
111
+ queue or claim those unsupported actions were scheduled.
112
+ - "Create a LinkedIn invitation batch for these exact profiles." Structure the
113
+ human's request into exact URLs, reasons, and notes, create the immutable
114
+ preview, and relay its human review path. Never approve through MCP.
94
115
  - "Who engaged with my last post?", "who liked it?", "what did people comment?",
95
116
  or "which warm signals need action?"
96
117
  - "Export my connections", "download my LinkedIn network", or "make me a
97
118
  connections CSV."
98
119
 
99
- Do not use SignalDash for public LinkedIn research, invitations, profile
100
- enrichment, scraping, bulk outreach, or a new email to someone with no existing
101
- thread. SignalDash exposes no LinkedIn invitation tool.
120
+ Do not use SignalDash for general public LinkedIn research, full-profile
121
+ enrichment, email finding, unreviewed bulk outreach, unapproved automatic
122
+ invitation processing, or a new email to someone with no existing thread.
123
+ `li_discover_people` is the narrow exception for one capped page of public
124
+ search cards after own-network search. Its results are planning evidence only.
125
+ Invitation tools act on one exact provider member or invitation at a time and
126
+ never authorize a list-wide loop. The only automatic incoming-invitation
127
+ exception is the explicitly enabled, filterable, rate-capped auto-accept worker.
128
+ The only multi-target outbound exception is an immutable 1–10-target invitation
129
+ batch with separate exact browser approval and server-controlled execution.
102
130
 
103
131
  ## The operating model
104
132
 
@@ -106,8 +134,10 @@ There are two surfaces:
106
134
 
107
135
  1. The CLI handles login, account connection, status, skill installation, MCP
108
136
  startup, logout, and the paced LinkedIn connections export.
109
- 2. MCP tools handle account reads, message sends, email, LinkedIn posts, and
110
- post engagement.
137
+ 2. MCP tools handle account reads, local connection search, capped paid
138
+ discovery, message sends, immutable invitation-batch create/inspect/cancel,
139
+ exact LinkedIn invitation actions, email, LinkedIn posts, and post
140
+ engagement.
111
141
 
112
142
  The CLI command used by the MCP registration is:
113
143
 
@@ -257,23 +287,30 @@ Use this sequence unless the request is read-only and ends before approval:
257
287
 
258
288
  1. **Confirm connection.** Run `npx -y @floomhq/signaldash status` when channel
259
289
  state is unknown. A connected status is required.
260
- 2. **Choose the narrowest list tool.** List recent chats, email threads, or the
261
- user's recent posts. Use a modest limit.
262
- 3. **Resolve the exact object.** Match the full chat name, thread, or post.
290
+ 2. **Choose the narrowest list or preview tool.** List recent chats, email
291
+ threads, invitation inboxes, or the user's recent posts. Preview an outbound
292
+ invitation through `li_send_invitation` with `confirm` omitted. Use a modest
293
+ limit.
294
+ 3. **Resolve the exact object.** Match the full chat name, thread, invitation,
295
+ provider member, or post.
263
296
  When names collide or identity is unclear, show the candidates and ask the
264
297
  user. Never infer from a partial name.
265
298
  4. **Read before interpreting.** Read enough recent history to understand the
266
299
  latest inbound message, earlier context, and existing outbound messages.
267
- For sends, read at least 10 recent items and use the exact chat or thread.
300
+ For message sends, read at least 10 recent items and use the exact chat or
301
+ thread. For invitation maintenance, list the exact current invitation. For
302
+ a new invitation, inspect the server-confirmed exact target and note preview.
268
303
  5. **Return findings or draft.** Summarize concrete facts. Separate suggested
269
304
  replies from messages already sent.
270
305
  6. **Obtain explicit approval.** Before any message or public post, show the
271
306
  exact channel, recipient, and complete text. For email include subject and
272
307
  body. Discussion, editing, "looks good", or approval of a different draft is
273
308
  not approval of the final action.
274
- 7. **Re-read immediately before sending.** Re-read the exact thread to catch a
275
- human reply, a manual send, or a duplicate that appeared after drafting. If
276
- context changed, revise and obtain approval again.
309
+ 7. **Re-read immediately before acting.** Re-read the exact thread or current
310
+ invitation list to catch a human action, reply, duplicate, acceptance, or
311
+ withdrawal that appeared after drafting. New invitation sends repeat the
312
+ exact previewed payload with `confirm:true`; the server rechecks relationship
313
+ and pending invitation state.
277
314
  8. **Act once.** Send one approved message or publish one approved post. Never
278
315
  parallelize sends and never loop over recipients.
279
316
  9. **Verify.** Read the exact thread again after a successful send. Confirm the
@@ -305,19 +342,51 @@ Use the exact tool names and argument keys below. Limits are optional.
305
342
 
306
343
  | Tool | Arguments | When to use it |
307
344
  |---|---|---|
308
- | `li_list_chats` | `limit` integer 1-100, default 20 | Find recent LinkedIn chats, unread counts, and exact `chat_id` values without opening profiles. |
345
+ | `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. |
309
346
  | `li_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Read one resolved LinkedIn conversation before summarizing, drafting, or sending. |
310
347
  | `li_send_message` | `chat_id` required; `text` required, max 5000 characters | Send one approved LinkedIn reply after an immediate read of that exact chat. |
311
- | `wa_list_chats` | `limit` integer 1-100, default 20 | Find an existing WhatsApp conversation and exact `chat_id`. |
348
+ | `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. |
349
+ | `li_invitations_received` | `limit` integer 1-100, default 50; `cursor` optional | List one bounded page of received invitations. This read authorizes only the exact returned invitation IDs for a later accept. |
350
+ | `li_accept_invitation` | `invitation_id` required; `confirm:true` required | Accept one exact currently pending received invitation after a fresh `li_invitations_received` read and approval. |
351
+ | `li_invitations_sent` | `limit` integer 1-100, default 50; `cursor` optional | List one bounded page of sent invitations. This read authorizes only the exact returned invitation IDs for a later withdrawal. |
352
+ | `li_withdraw_invitation` | `invitation_id` required; `confirm:true` required | Withdraw one exact currently pending sent invitation after a fresh `li_invitations_sent` read and approval. |
353
+ | `sd_contact_state` | `channel` required (`linkedin`, `whatsapp`, or `email`); `identifiers` required array of 1-8 exact `{kind,value}` objects; `action` optional (`get` default or `suppress`); suppression also requires an allowed `reason` and `confirm:true` | Inspect exact tenant/channel-scoped contact history or add a protective suppression. It never infers that identifiers on different channels belong to one person. |
354
+ | `sd_budget_status` | no arguments | Read the current sender binding, daily total/manual/campaign/unknown attempts, total and combined-campaign capacity, weekly invitation usage, lock state, and UTC resets from the authoritative server ledger. |
355
+ | `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. |
356
+ | `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. |
357
+ | `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. |
358
+ | `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. |
359
+ | `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. |
360
+ | `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`. |
361
+ | `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. |
362
+ | `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. |
363
+ | `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. |
364
+ | `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. |
365
+ | `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. |
366
+ | `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. |
367
+ | `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. |
368
+ | `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. |
369
+ | `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. |
370
+ | `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. |
371
+ | `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. |
372
+ | `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. |
373
+ | `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`. |
312
374
  | `wa_read_messages` | `chat_id` required; `limit` 1-100, default 30 | Verify a WhatsApp contact and recent history before summarizing, drafting, or sending. |
313
- | `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. |
375
+ | `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. |
376
+ | `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. |
377
+ | `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. LinkedIn messages carry text only. |
378
+ | `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. |
379
+ | `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. |
314
380
  | `email_list` | `limit` integer 1-100, default 20 | List the newest message in each recent email thread and obtain `thread_id`. |
315
381
  | `email_read` | `thread_id` required; `limit` 1-100, default 30 | Read an email thread and authorize its exact participant addresses for a later send. |
316
382
  | `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. |
317
383
  | `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. |
318
384
  | `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. |
319
385
  | `li_post_comments` | `post_id` required; `limit` default 50, max 100 | Read comments and authors for one post; prioritize questions and substantive responses. |
320
- | `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. |
386
+ | `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. |
387
+ | `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. |
388
+ | `li_scheduled_posts` | no arguments | List only this authenticated user's scheduled LinkedIn posts and their durable states. |
389
+ | `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. |
321
390
 
322
391
  Representative calls:
323
392
 
@@ -325,9 +394,40 @@ Representative calls:
325
394
  li_list_chats({"limit":20})
326
395
  li_read_messages({"chat_id":"chat_li_7f3a","limit":20})
327
396
  li_send_message({"chat_id":"chat_li_7f3a","text":"Yes. I’ll send it this afternoon."})
397
+ li_send_invitation({"provider_id":"ACoAAExactMember","note":"Hi Amina, I enjoyed your post on agent safety."})
398
+ li_send_invitation({"provider_id":"ACoAAExactMember","note":"Hi Amina, I enjoyed your post on agent safety.","confirm":true})
399
+ li_invitations_received({"limit":20})
400
+ li_accept_invitation({"invitation_id":"invite_received_42","confirm":true})
401
+ li_invitations_sent({"limit":20})
402
+ li_withdraw_invitation({"invitation_id":"invite_sent_91","confirm":true})
403
+ sd_contact_state({"channel":"linkedin","identifiers":[{"kind":"provider_id","value":"ACoAAExactMember"}]})
404
+ sd_contact_state({"channel":"email","identifiers":[{"kind":"email","value":"amina@example.com"}],"action":"suppress","reason":"opt_out","confirm":true})
405
+ sd_budget_status({})
406
+ sd_settings_get({})
407
+ sd_settings_set({"auto_accept_linkedin":true,"auto_accept_linkedin_filters":{"public_identifiers":["amina-rahman"],"description_keywords":["Founder"]},"confirm":true})
408
+ sd_auto_accept_status({})
409
+ sd_voice_profile({"channel":"whatsapp"})
410
+ sd_voice_profile({"channel":"linkedin","force_recompute":true})
411
+ li_search_connections({"query":"founder agents","filters":{"company":"Acme","headline_keyword":"Founder","connected_after":"2025-01-01","connected_before":"2026-01-01"},"limit":20})
412
+ li_discover_people({"query":"Platform Engineer","filters":{"company":"Acme","location":"Berlin"},"limit":5})
413
+ li_discover_people({"query":"Platform Engineer","filters":{"company":"Acme","location":"Berlin"},"limit":5,"confirm":true})
414
+ 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."}]})
415
+ li_get_invitation_batch({"batch_id":"00000000-0000-4000-8000-000000000000"})
416
+ li_cancel_invitation_batch({"batch_id":"00000000-0000-4000-8000-000000000000","approval_view_hash":"exact-64-character-hash-from-the-read","confirm":true})
417
+ 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."}]})
418
+ 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}]})
419
+ sd_campaign_preview({"campaign_id":"00000000-0000-4000-8000-000000000000"})
420
+ sd_campaign_approve({"campaign_id":"00000000-0000-4000-8000-000000000000","confirm_token":"sd-4f2a-91bc-73de"})
421
+ sd_campaign_status({"campaign_id":"00000000-0000-4000-8000-000000000000"})
422
+ sd_campaign_cancel({"campaign_id":"00000000-0000-4000-8000-000000000000","approval_view_hash":"exact-64-character-hash-from-the-read","confirm":true})
328
423
  wa_list_chats({"limit":20})
329
424
  wa_read_messages({"chat_id":"chat_wa_91b2","limit":20})
425
+ wa_get_attachment({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71","attachment_id":"att_wa_0a33"})
426
+ wa_transcribe_voice({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71","attachment_id":"att_wa_0a33"})
330
427
  wa_send_message({"chat_id":"chat_wa_91b2","text":"16:30 works. See you then."})
428
+ wa_send_message({"chat_id":"chat_wa_91b2","text":"Q3 numbers attached.","attachments":[{"filename":"q3-arr.csv","content_type":"text/csv","content_base64":"<base64>"}]})
429
+ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
430
+ wa_delete_messages({"messages":[{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"},{"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c72"}]})
331
431
  email_list({"limit":20})
332
432
  email_read({"thread_id":"thread_email_c402","limit":30})
333
433
  email_send({"to":["amina@example.com"],"subject":"Re: Case study","body":"Hi Amina,\n\nHere is the case study."})
@@ -335,6 +435,9 @@ li_my_posts({"limit":5})
335
435
  li_post_reactions({"post_id":"post_urn_8821","limit":50})
336
436
  li_post_comments({"post_id":"post_urn_8821","limit":50})
337
437
  li_draft_post({"text":"Most agents need better context, not more autonomy."})
438
+ li_set_scheduled_post_first_comment({"id":"00000000-0000-4000-8000-000000000000","first_comment":"https://github.com/xai-org/x-algorithm","confirm":true})
439
+ li_scheduled_posts({})
440
+ li_cancel_scheduled_post({"id":"00000000-0000-4000-8000-000000000000","confirm":true})
338
441
  ```
339
442
 
340
443
  List and read success returns JSON with `items` and often a `cursor`. A message
@@ -351,13 +454,478 @@ result is deterministic:
351
454
  ```
352
455
 
353
456
  Publishing is public and irreversible. Never interpret "draft a post" as
354
- permission to publish. `email_send` cannot start a cold thread and must never be
355
- looped over recipients.
457
+ permission to publish or schedule. For a future post, first call
458
+ `li_draft_post` without `publish:true` and show the exact returned text,
459
+ offset-qualified UTC instant, mentions, attachment count, and first comment.
460
+ After explicit
461
+ approval, repeat the identical payload with `publish:true`. Verify the stored
462
+ record with `li_scheduled_posts`. For an existing scheduled post, use
463
+ `li_set_scheduled_post_first_comment` only after approval of the exact comment.
464
+ SignalDash persists the published post ID before sending the comment, so a
465
+ comment failure never republishes the post. It fails interrupted or ambiguous
466
+ executions closed and never retries them automatically. `email_send` cannot
467
+ start a cold thread and must never be looped over recipients.
468
+
469
+ ### Exact contact state and suppression
470
+
471
+ `sd_contact_state` is local and account-scoped. It makes no Unipile, LinkedIn,
472
+ WhatsApp, or email provider request. Exact identifiers are keyed separately by
473
+ user and channel, and the response does not echo their values.
474
+
475
+ Allowed identifier kinds:
476
+
477
+ - LinkedIn: `provider_id`, `public_identifier`, canonical `profile_url`,
478
+ `member_urn`, or `chat_id`.
479
+ - WhatsApp: `chat_id` or an E.164 `phone`.
480
+ - Email: normalized `email`.
481
+
482
+ One fresh provider response can supply several exact identifiers for the same
483
+ channel. Pass those together to get the conservative union of their state.
484
+ Never combine identifiers based on a name, employer, headline, similar profile,
485
+ phone guess, or email guess. SignalDash does not infer cross-channel identity
486
+ and does not claim person-wide suppression.
487
+
488
+ Use `action:"get"` before outreach planning. `campaign_eligible:false` and
489
+ `blocking_reasons` report known replies, earlier outbound touches,
490
+ already-connected state, or suppression. Automated campaign actions are
491
+ rejected after a known reply or earlier touch. Manual approved replies remain
492
+ available unless an exact identifier is suppressed.
493
+
494
+ Use `action:"suppress"` only after the human explicitly asks to suppress the
495
+ exact identifier. Show the channel, identifier kind, and reason, then repeat
496
+ with `confirm:true`. Allowed reasons are `opt_out`, `not_interested`,
497
+ `provider_block`, `manual`, and `legal`. MCP cannot clear a suppression.
498
+
499
+ ### LinkedIn action budget status
500
+
501
+ `sd_budget_status` is read-only and account-scoped. Use it before planning
502
+ manual LinkedIn work or any campaign action. It reports attempts that
503
+ SignalDash has committed in its authoritative SQLite ledger:
504
+
505
+ - the total daily cap (`H`);
506
+ - the campaign-excluded portion (`R = min(4, H)`);
507
+ - the combined ceiling across all campaigns (`C = H - R`);
508
+ - total, manual, campaign, and unknown attempts for the UTC policy day;
509
+ - raw total and combined-campaign capacity remaining;
510
+ - actions available now after connection and sender-lock state;
511
+ - weekly LinkedIn invitation usage and its Monday 00:00 UTC reset.
512
+
513
+ `R` is capacity campaigns cannot consume. It is not a reserve or guaranteed
514
+ manual allowance: manual activity can consume the total cap first, and a later
515
+ manual action can still be rejected at `H`. Campaign capacity is shared across
516
+ all campaigns and is additionally bounded by the total remaining capacity.
517
+
518
+ The result counts SignalDash-recorded attempts only. Native LinkedIn activity
519
+ is not fully counted, so never describe the result as a LinkedIn-safe
520
+ allowance. A disconnected, unverified, or locked sender reports zero safe
521
+ actions even when raw counter capacity remains. It reports the durable pacing
522
+ time and work window after a campaign action has established them. The
523
+ scheduler, persisted tenant-breaker, active-batch, and target-count fields
524
+ reflect the current invitation-batch runtime.
525
+
526
+ ### Opt-in invitation auto-accept
527
+
528
+ `sd_settings_get` is the single read surface for persistent SignalDash
529
+ settings. `sd_settings_set` changes persistent behavior for only the
530
+ authenticated SignalDash user. Auto-accept is the first supported setting;
531
+ every existing and newly created user is disabled by default. Show the exact
532
+ setting and filters, obtain explicit human approval, then call the setter once
533
+ with `confirm:true`. Disabling also uses `confirm:true`. Never infer changes to
534
+ future or unknown settings.
535
+
536
+ With no filters, every parseable pending LinkedIn invitation is eligible. The
537
+ optional `public_identifiers` list is an exact lowercase allowlist. Optional
538
+ `description_keywords` are case- and accent-insensitive substring matches
539
+ against the provider's inviter description. When both groups are present, both
540
+ must match. Names are never used as identity filters.
541
+
542
+ The server worker is the action authority. By default it reads one bounded
543
+ page, accepts no more than five invitations per run and ten per UTC day,
544
+ jitters before every fresh preflight, serializes writes per sender, consumes
545
+ the shared LinkedIn daily budget, and stops on a warning, 403, 429, unknown
546
+ outcome, sender lock, or three consecutive errors. Successful acceptance is
547
+ written to the action audit and exact LinkedIn contact state as inbound,
548
+ invited, and connected, so later campaigns see the relationship.
549
+ A healthy worker run resets the consecutive provider-error streak; malformed
550
+ invitations remain visible in status even after later healthy runs.
551
+
552
+ Unipile currently places acceptance proof at `specifics.shared_secret` and
553
+ inviter data at
554
+ `inviter.{inviter_name,inviter_public_identifier,inviter_description}`.
555
+ SignalDash also reads the legacy top-level secret and old inviter fields. It
556
+ never returns or logs the secret. An invitation missing its ID, acceptance
557
+ proof, or an exact inviter identifier is not accepted: the server emits a
558
+ high-visibility error, stores only sanitized missing-field evidence, and
559
+ exposes it through `sd_auto_accept_status`. Three consecutive parse or provider
560
+ errors disable automation with `disabled_reason:"repeated_errors"`.
561
+
562
+ Use `sd_auto_accept_status` after changing the setting and whenever the user
563
+ asks whether it is healthy. Report `enabled`, exact filters, accepted today and
564
+ this week, failed attempts today, remaining dedicated and shared capacity,
565
+ `consecutive_errors`, `disabled_reason`, and every recent unparseable record.
566
+ Never describe a disabled or erroring worker as active.
567
+
568
+ ### Post scheduling and campaign time boundary
569
+
570
+ SignalDash supports an exact one-time future LinkedIn post through
571
+ `li_draft_post`. It does not infer a timezone: `scheduled_at` must contain `Z`
572
+ or an explicit numeric UTC offset. Mention names must appear exactly in the
573
+ approved text. Image bytes are frozen with the schedule, so later file changes
574
+ cannot alter the approved payload. An optional approved `first_comment` is
575
+ frozen with the same schedule and is posted through the same connected account
576
+ after the post provider ID is durably stored. A failed or ambiguous comment is
577
+ never retried and never causes the post to be published again. Use
578
+ `li_scheduled_posts` to verify the durable post and comment states. Cancellation
579
+ requires a fresh list, the exact id, approval, and
580
+ `confirm:true`; it works only while state is `scheduled`.
581
+
582
+ SignalDash does not expose a general message scheduler. The first executable
583
+ campaign scope has no user-selected future start date, recurring schedule,
584
+ automatic follow-up, acceptance-triggered message, or multi-message
585
+ ("double text") sequence. Do not claim that any such action was queued.
586
+
587
+ The common server write authority enforces the design-approved time controls
588
+ for every campaign action:
589
+
590
+ - a valid sender IANA timezone is mandatory;
591
+ - actions run only Monday through Friday, 09:00-17:00 sender-local time;
592
+ - every attempted action atomically stores the next per-sender pacing time,
593
+ randomized from 90 to 180 seconds;
594
+ - timing is checked before final provider preflight and again in the same
595
+ SQLite transaction that reserves budget and acquires the sender lease;
596
+ - one provider write may be in flight per sender;
597
+ - downtime, a work-window boundary, or a UTC reset never creates a catch-up
598
+ burst.
599
+
600
+ Manual one-object tools keep their existing exact-read, approval, duplicate,
601
+ rate, ownership, contact-state, and sender-lock guards. A later message after
602
+ an invitation or reply is new context and requires a fresh thread read, exact
603
+ draft, and human approval.
604
+
605
+ ### Human-approved invitation batches
606
+
607
+ An invitation batch is the invitation-only form: it stops permanently at the
608
+ invitation result. For the request-then-message loop use the campaign tools in
609
+ the next section. A batch contains 1–10
610
+ exact canonical LinkedIn Classic profile URLs, one exact inclusion reason per
611
+ target, and one optional exact invitation note per target. The note limit is
612
+ 200 Unicode code points. Source labels, reasons, and notes reject leading or
613
+ trailing whitespace; notes normalize line endings and reject control or format
614
+ characters.
615
+
616
+ The human can describe the desired batch naturally, but the agent must
617
+ structure only facts the human explicitly supplied. Never infer or generate a
618
+ profile URL, inclusion reason, or note. Search, discovery, reactions, comments,
619
+ and connection exports are not batch target sources. The server performs no
620
+ runtime target or text generation.
621
+
622
+ Use the batch tools in this order:
623
+
624
+ 1. Call `li_create_invitation_batch` once with the exact source label, sender
625
+ IANA timezone, and complete ordered target list.
626
+ 2. Poll only with `li_get_invitation_batch` until the durable preview is
627
+ `previewed` or terminal. Inspect every target, including exclusions.
628
+ 3. Relay the returned `approval_url` to the human. The human opens it in a
629
+ browser, reauthenticates with the SignalDash invite credential, checks each
630
+ desired target (none are preselected), acknowledges the consequences, and
631
+ approves. MCP and agent bearer tokens cannot approve. Five failed credential
632
+ attempts durably block that batch's authentication surface for 15 minutes.
633
+ 4. Continue read-only status checks with `li_get_invitation_batch`. The worker
634
+ submits selected targets in fixed order only while the 24-hour approval,
635
+ sender binding, weekday work window, pacing, budgets, exact identity,
636
+ contact state, invitation state, chat absence, and tenant breaker remain
637
+ valid.
638
+ 5. To stop, first inspect the batch, show the exact state and hash, obtain
639
+ cancellation approval, then call `li_cancel_invitation_batch` once with
640
+ that hash and `confirm:true`.
641
+
642
+ Cancel permanently terminates the batch and affects only unstarted targets.
643
+ It cannot recall an executing provider action. A restart requires a new batch,
644
+ preview, and human approval. A batch itself has no pause/resume, priority,
645
+ future start, recurring schedule, follow-up, acceptance polling, acceptance
646
+ message, or action after a successful invitation. Those belong to the separate
647
+ campaign loop below, which requires its own explicit human approval of both the
648
+ invitation and every message.
649
+
650
+ Unknown outcomes, provider warnings, checkpoints, HTTP 403, and HTTP 429 stop
651
+ the batch and lock the sender. Two campaign `outcome_unknown` writes on
652
+ different senders within five minutes open the persisted tenant breaker.
653
+ Tenant provider authentication failures open it immediately. While open, the
654
+ breaker rejects new batch creation and stops queued preview work before any
655
+ provider read.
656
+ Definite provider rejections consume attempts but can leave later exact targets
657
+ eligible to run. SignalDash limits are not LinkedIn-safe thresholds, and
658
+ native LinkedIn activity can still race the final reads.
659
+
660
+
661
+ ### The human-approved campaign loop
662
+
663
+ A campaign is exactly one thing: a connection request, then the approved
664
+ message once that person accepts. It is the only place SignalDash acts after an
665
+ acceptance, and it acts only on the exact text a human read and approved.
666
+
667
+ What the server proves, and refuses to assume:
668
+
669
+ - A pending invitation that simply disappeared is NOT an acceptance. It may have
670
+ been withdrawn, expired, or declined. A target advances only when a fresh
671
+ profile read, fetched by the STABLE provider id rather than the mutable
672
+ `/in/<slug>` vanity URL, returns BOTH that same provider id and a connected
673
+ network distance. If the profile now resolves to a different member, the
674
+ target is excluded as `target_identity_changed` and is never messaged: a
675
+ vanity URL can be freed and reissued to somebody else. A distance SignalDash
676
+ cannot classify leaves the target waiting and is reported in
677
+ `sd_campaign_status.parse_failures`, never guessed in either direction.
678
+ - The sequence stops permanently the moment that person sends anything back.
679
+ - A first message is refused if the thread already holds an outbound message,
680
+ and any step whose exact text is already in the thread is skipped.
681
+ - A provider list SignalDash cannot recognise is never read as an empty list.
682
+ Every absence proof -- no reply, no prior outbound, no existing conversation,
683
+ no pending invitation -- blocks and is reported rather than passing silently.
684
+ - Campaign invitations and campaign messages consume the SAME per-sender daily
685
+ budget as your manual sends, plus the campaign ceiling and the weekly
686
+ invitation cap. They obey the Monday-Friday 09:00-17:00 sender-local window
687
+ and the 90-180 second pacing interval, and they never burst to catch up.
688
+ - Building a target list from post engagers fetches no profiles: the reactions
689
+ and comments endpoints already return the member id and the network distance.
690
+ Prefer that and `li_search_connections` over anything that opens profiles.
691
+
692
+ Use the campaign tools in this order:
693
+
694
+ 1. Get the exact people and the exact words from the human. Draft the messages
695
+ in the user's own voice and keep them short: the on-acceptance group is two
696
+ or three separate sends, never one block. Never invent a recipient or a
697
+ sentence. Ask whether they already sent any of these people a connection
698
+ request themselves; if so, mark that target `adopt_existing_invitation`
699
+ instead of dropping them.
700
+ 2. Call `sd_campaign_create` once.
701
+ 3. Poll `sd_campaign_preview` until the campaign is `previewed` or terminal.
702
+ Show the human every recipient, every exclusion and its reason, and the exact
703
+ text of every message step.
704
+ 4. Relay the returned `approval_url`. The human authenticates with the
705
+ SignalDash invite credential, reads the rows, ticks the recipients they
706
+ approve (none are preselected), and acknowledges the consequences. They then
707
+ either approve there, or press the button that mints a one-time
708
+ `confirm_token` for you. Only then call `sd_campaign_approve`. An agent
709
+ bearer token cannot approve, and you cannot mint that code. Five failed
710
+ credential attempts durably block that campaign's authentication surface for
711
+ 15 minutes.
712
+ 5. Watch progress with `sd_campaign_status`. Report acceptances, replies,
713
+ exclusions, and parse failures honestly, including people who never accepted.
714
+ 6. To stop, inspect first, show the exact state and `approval_view_hash`, obtain
715
+ cancellation approval, then call `sd_campaign_cancel` once with that hash and
716
+ `confirm:true`.
717
+
718
+ #### The user already sent the invitation by hand
719
+
720
+ The most common real shape is not a cold list. It is: the human sent somebody a
721
+ connection request themselves, from the LinkedIn app, and now wants the
722
+ follow-up automated. A campaign normally REFUSES that person with
723
+ `invitation_already_pending`, because that exclusion exists to stop a duplicate
724
+ invitation.
725
+
726
+ Set `adopt_existing_invitation: true` on that exact target instead of leaving
727
+ them out. It inverts one check and nothing else:
728
+
729
+ - a still-pending sent invitation to that person becomes REQUIRED, not
730
+ disqualifying;
731
+ - SignalDash sends **no invitation at all** for that person, ever. If the
732
+ pending invitation is not there when SignalDash looks, the person is dropped
733
+ with `adopted_invitation_missing`. A missing invitation is never replaced by
734
+ a new one, so this can never become a send the human did not ask for;
735
+ - the invitation is matched to the target by the STABLE PROVIDER ID from a
736
+ fresh profile read, never by the profile URL. LinkedIn frees and reissues
737
+ vanity URLs, so a URL can resolve to a different human;
738
+ - the approval page states, in that person's own row, that no invitation is
739
+ sent, which invitation is being adopted, and how old it is. Say the same
740
+ thing to the human before you send them the link;
741
+ - an adopted target may not carry a `note`, because no invitation goes out and
742
+ a note would only misrepresent what is about to happen;
743
+ - at most 10 adopted targets per campaign: each one costs one profile read in
744
+ the preview to resolve its provider id.
745
+
746
+ Everything after that is identical to any other target. Acceptance is still
747
+ proven only by a fresh profile read showing a connected network distance, the
748
+ messages still need their own approval on the same page, the thread is still
749
+ read before the first message, and anything in that thread the campaign did not
750
+ send halts the sequence permanently. If the recipient answered the invitation
751
+ note without accepting, that conversation already exists and the target is
752
+ dropped with `existing_conversation`: it is a human conversation in progress,
753
+ not a campaign to resume.
754
+
755
+ The acceptance window (`invite_ttl_days`) for an adopted target is counted from
756
+ the moment SignalDash adopts it, not from the hand-sent invitation, whose age
757
+ LinkedIn only reports as a bucket. Tell the human that if it matters.
758
+
759
+ An invitation nobody accepted within `invite_ttl_days` is dropped and never
760
+ messaged. The approval window is derived from the schedule the human approved --
761
+ the acceptance window plus every follow-up wait plus two days -- so no approved
762
+ step can fall outside its own receipt, and a schedule that would not fit inside
763
+ 60 days is refused at creation. Every individual write still re-proves identity,
764
+ relationship, pending state, and conversation absence immediately before it
765
+ happens, and `sd_campaign_status` reports the exact expiry.
766
+
767
+ ### Clearing a backlog of old pending sent invitations
768
+
769
+ A user with hundreds or thousands of pending sent invitations has no supported
770
+ way to clear them one at a time: `li_withdraw_invitation` needs `confirm:true`
771
+ per action, and generating 824 confirmations would defeat that contract rather
772
+ than satisfy it. The withdrawal sweep is the supported path.
773
+
774
+ **Two things you MUST tell the human, because both are LinkedIn's own
775
+ documented behaviour, and getting either wrong is a false claim:**
776
+
777
+ 1. **This does NOT free up sending capacity.** LinkedIn's help page states that
778
+ withdrawing an invitation does not lift an active sending restriction.
779
+ Withdrawal and the weekly invitation limit are decoupled. Never sell this as
780
+ a way to send more. What it does do is clear a stale backlog, and LinkedIn
781
+ does name invitations that are "ignored, left pending, or marked as spam"
782
+ among the things it looks at.
783
+ 2. **After withdrawing, the human cannot re-invite that person for up to three
784
+ weeks.** That cost is per person and irreversible for that window. Approving
785
+ 824 withdrawals accepts it for 824 people. Say the number.
786
+
787
+ Do not repeat the widely-quoted "3000 pending invitations" ceiling. It is in no
788
+ LinkedIn document and vendor numbers contradict each other. Do not suggest that
789
+ invitation behaviour causes hidden reach suppression; LinkedIn's restrictions
790
+ are explicit and notified.
791
+
792
+ **`exclude` is required and load bearing.** Ask the human, before you call
793
+ anything, who must NOT be withdrawn. A real request came with three specific
794
+ people the owner had just protected, and two of the three fell inside his own
795
+ blanket age rule. Passing `[]` is allowed but it means nobody is protected, and
796
+ the review page says so in those words. An exclusion that matches nobody fails
797
+ the preview by default: that is the shape of a typo, and a typo means the
798
+ person they meant to protect ends up in the withdraw list.
799
+
800
+ **Age labels are buckets, not dates.** LinkedIn does not expose a send date. It
801
+ exposes "Sent 4 months ago", and the timestamp is derived from that label, so
802
+ ordering inside a bucket is meaningless and any day-based filter is
803
+ bucket-accurate at best. SignalDash selects an invitation only when its WHOLE
804
+ bucket is older than the threshold. On a real account a naive label filter
805
+ matched 1384 invitations while this conservative rule matched 1036; the 348
806
+ difference is people whose bucket straddles the threshold. They are held back,
807
+ because withdrawing them costs three weeks and cannot be undone.
808
+ `sd_withdrawal_batch_status` reports both counts.
809
+
810
+ Pace and budget:
811
+
812
+ - One withdrawal per tick, 4-12 seconds apart, at most 44 a day across 4 short
813
+ runs of 11 with a 45-minute gap between runs. Never one continuous session.
814
+ The only documented hard failure was roughly 8000 withdrawals in one
815
+ uninterrupted burst producing "Account Network Blocked".
816
+ - Monday-Friday 09:00-17:00 in the sender timezone you supply.
817
+ - The sweep has its OWN daily allowance and never consumes the send budget, so
818
+ it cannot starve real invitations and messages. It is visible in
819
+ `sd_budget_status` under `withdrawal_sweep`, explicitly not counted in
820
+ `safe_actions_remaining_today`.
821
+ - A 403, a 429, any provider warning, an unrecognised provider response, or an
822
+ account status change stops the WHOLE sweep, not just one item. Restarting
823
+ needs a new preview and a new human approval.
824
+ - An invitation that stopped being pending, because the person accepted or it
825
+ expired, is skipped rather than forced, and the sweep continues.
826
+
827
+ Use the tools in this order:
828
+
829
+ 1. Ask the human for the age rule and, explicitly, the people to protect.
830
+ 2. Call `sd_withdrawal_batch_create` once with `exclude` filled in.
831
+ 3. Poll `sd_withdrawal_batch_status` until it is `previewed` or terminal. Show
832
+ the human the count, the protected names, both age counts, the three-week
833
+ consequence, and that this does not free sending capacity.
834
+ 4. Relay the returned `approval_url`. The human authenticates, reads the rows,
835
+ unticks anyone they want kept, and either approves there or presses the
836
+ button that mints a one-time `confirm_token` for you. Only then call
837
+ `sd_withdrawal_batch_approve`. An agent bearer token cannot approve.
838
+ 5. Watch with `sd_withdrawal_batch_status`. Report the stop reason honestly if
839
+ it stopped.
840
+ 6. To stop early, inspect first, then `sd_withdrawal_batch_cancel` with the
841
+ `approval_view_hash` it returned and `confirm:true`.
842
+
843
+ ### Search your stored LinkedIn network
844
+
845
+ `li_search_connections` is the primary list-building path before any external
846
+ discovery. It is account-scoped and searches the stored connection sidecar
847
+ only. The request path performs zero LinkedIn, Unipile, HarvestAPI, or other
848
+ paid API calls and never starts or resumes a sync.
849
+
850
+ The required `query` matches accent-insensitive terms across name, headline,
851
+ and stored company. Optional `company` and `headline_keyword` filters are
852
+ case-insensitive substrings. `connected_after` and `connected_before` use
853
+ inclusive `YYYY-MM-DD` bounds. Legacy v1 relation rows do not contain a
854
+ separate company field, so their company filter is transparently matched
855
+ against the returned headline.
856
+
857
+ Each result contains only:
858
+
859
+ - `name`, `headline`, `public_id`, `profile_url`, and `connected_at`;
860
+ - `already_in_contact`, based on exact recorded inbound, outbound, reply, or
861
+ invitation history;
862
+ - the joined `contact_state`, `campaign_eligible`, and `blocking_reasons`.
863
+
864
+ The join checks only the row's exact LinkedIn public identifier and canonical
865
+ profile URL for this SignalDash user. It does not infer a person, merge aliases,
866
+ or inspect another tenant. Search results are planning evidence, not permission
867
+ to message or invite anyone. A later action still requires its exact
868
+ one-object read/preview, human review, approval, fresh preflight, and server
869
+ guards. The executable invitation-batch design does not accept a search result
870
+ as a target input.
871
+
872
+ When no connection rows are stored, the tool returns
873
+ `connections_not_synced` or `connections_sync_pending` and points to:
874
+
875
+ ```bash
876
+ npx -y @floomhq/signaldash connections linkedin-connections.csv
877
+ ```
878
+
879
+ Use that paced, resumable sync. Never replace the missing snapshot with a burst
880
+ of profile reads.
881
+
882
+ ### Capped discovery beyond the stored network
883
+
884
+ `li_discover_people` is the only supported paid discovery path. It searches
885
+ public LinkedIn profile cards through HarvestAPI without using the connected
886
+ LinkedIn sender. It does not fetch full profiles, find email addresses, send
887
+ anything, or create invitation-batch targets.
888
+
889
+ The server enforces local-first behavior. A completed paced connection snapshot
890
+ is mandatory. SignalDash searches that snapshot with the requested role/title
891
+ and company before any paid request. When local matches exist, it returns those
892
+ matches with `source:"own_network"` and `provider_requests:0`; use
893
+ `li_search_connections` to refine them. An incomplete snapshot returns
894
+ `connections_sync_required` and starts no provider request.
895
+
896
+ When there are no local matches:
897
+
898
+ 1. Call `li_discover_people` without `confirm`. The response previews the exact
899
+ query, filters, result limit, ten-profile maximum billing exposure, maximum
900
+ reserved cost, and 30-minute expiry. It makes zero paid requests.
901
+ 2. Show the human that exact paid request and maximum reserved cost. Obtain
902
+ explicit approval.
903
+ 3. Repeat the identical arguments once with `confirm:true`.
904
+
905
+ The hosted server then makes at most one serialized
906
+ `GET /linkedin/profile-search` request for page 1. It reserves the configured
907
+ worst-case page cost in integer micro-dollars before the request and enforces a
908
+ durable per-user cooldown, per-user daily request cap, tenant-wide daily
909
+ request cap, and tenant-wide daily cost cap. The reservation consumes the exact
910
+ preview atomically. Another paid request, including one after a failed or
911
+ ambiguous provider result, requires a fresh preview and approval. Failed or
912
+ ambiguous provider requests retain their reservation and are never retried
913
+ automatically.
914
+
915
+ SignalDash removes hidden `"LinkedIn Member"` cards, malformed or ambiguous
916
+ identities, duplicates, exact stored connections, and exact contact-state rows
917
+ already marked connected. Returned cards contain only name, headline,
918
+ location, public identifier, canonical profile URL, and exact local contact
919
+ state. No fuzzy or cross-channel identity merge occurs.
920
+
921
+ Discovery results are planning evidence only. `CAMPAIGN-DESIGN.md` excludes
922
+ searches and discovery as invitation-batch target sources. A later action on
923
+ one exact person begins the complete one-object preview, approval, fresh
924
+ preflight, contact-state, duplicate, ownership, and rate-limit flow.
356
925
 
357
926
  ### LinkedIn connections export via CLI
358
927
 
359
- This is the fourteenth operation. It is intentionally a paced CLI workflow, not
360
- an MCP bulk-read tool.
928
+ This is intentionally a paced CLI workflow, not an MCP bulk-read tool.
361
929
 
362
930
  ```bash
363
931
  npx -y @floomhq/signaldash connections linkedin-connections.csv
@@ -389,8 +957,9 @@ different calling order cannot bypass them.
389
957
  ### 428 `read_before_send_required`
390
958
 
391
959
  Meaning: this user has not successfully read the exact chat recently, or the
392
- email recipient was not present in a recently read thread. The default read
393
- window is 30 minutes.
960
+ email recipient was not present in a recently read thread, or an invitation
961
+ write lacks its exact recent preview/list read. The default read window is 30
962
+ minutes.
394
963
 
395
964
  Comply:
396
965
 
@@ -417,6 +986,41 @@ Comply:
417
986
 
418
987
  Changing whitespace or punctuation to evade the duplicate guard is prohibited.
419
988
 
989
+ ### 409 `contact_suppressed`
990
+
991
+ Meaning: one of the exact channel identifiers for this contact has an active
992
+ suppression. Message and invitation writes stop before the provider write.
993
+
994
+ Comply:
995
+
996
+ 1. Stop the action.
997
+ 2. Inspect the exact state with `sd_contact_state`.
998
+ 3. Do not switch identifiers, channels, accounts, or sessions to evade it.
999
+ 4. Escalate a mistaken suppression to the SignalDash operator. MCP cannot clear
1000
+ it.
1001
+
1002
+ Campaign-mode actions also reject `recipient_replied` and `already_contacted`.
1003
+ These blocks prevent automated follow-up after a known inbound message and
1004
+ prevent another campaign from touching the same exact identifier. They do not
1005
+ claim that SignalDash has resolved a person across channels.
1006
+
1007
+ ### 409 invitation and context preflight blocks
1008
+
1009
+ `thread_changed`, `already_connected`, `existing_conversation`,
1010
+ `conversation_state_incomplete`, `invitation_already_pending`,
1011
+ `inbound_invitation_pending`, `invitation_not_pending`,
1012
+ `invitation_state_incomplete`, and `relationship_unverified` mean the exact
1013
+ provider state no longer authorizes the action. New-invitation preflight checks
1014
+ up to 250 sent invitations, up to 100 received invitations, and the exact
1015
+ target's attendee-scoped chats. Any pagination cursor makes absence unproved.
1016
+
1017
+ Comply:
1018
+
1019
+ 1. Stop the action.
1020
+ 2. Re-read the exact thread or invitation list.
1021
+ 3. Do not expand pagination or fetch profiles in a loop.
1022
+ 4. Obtain new approval only for a newly previewed exact action.
1023
+
420
1024
  ### 429 `rate_limit_exceeded`
421
1025
 
422
1026
  Meaning: the persisted daily action cap is exhausted. The response includes
@@ -431,21 +1035,54 @@ Comply:
431
1035
  to work around the cap.
432
1036
  4. Do not queue a burst for the reset boundary.
433
1037
 
1038
+ Invitation sends also return `invitation_rate_limit_exceeded` when the separate
1039
+ weekly policy is exhausted. The default policy is 100 attempts per sender,
1040
+ resetting Monday at 00:00 UTC. It is a SignalDash policy, not a claim about a
1041
+ LinkedIn-safe threshold.
1042
+
1043
+ Paid discovery returns `paid_discovery_cooldown`,
1044
+ `paid_discovery_user_daily_cap`, `paid_discovery_tenant_daily_cap`, or
1045
+ `paid_discovery_cost_cap` before the HarvestAPI request when its pacing,
1046
+ request, or spend boundary is reached. Stop and use `retry_at` or the UTC reset
1047
+ reported by the server. Do not switch users, sessions, or machines to bypass a
1048
+ paid-provider cap.
1049
+
1050
+ ### 423 sender locks and 502 `outcome_unknown`
1051
+
1052
+ A provider warning, HTTP 403, HTTP 429, sender identity mismatch, or ambiguous
1053
+ provider outcome locks all later LinkedIn writes for that logical sender. An
1054
+ ambiguous result returns `outcome_unknown`, consumes the action budget, and is
1055
+ never retryable.
1056
+
1057
+ Comply:
1058
+
1059
+ 1. Do not retry or switch sessions.
1060
+ 2. Inspect the provider account manually for a restriction and verify whether
1061
+ the action landed.
1062
+ 3. Escalate for human reconciliation. Only the hosted operator can clear the
1063
+ durable lock after verification.
1064
+
434
1065
  Any upstream 429, provider warning, checkpoint, restriction, unusual-activity
435
1066
  prompt, or HTTP 403 also means stop. Do not retry.
436
1067
 
437
1068
  ## Before every send
438
1069
 
439
- These rules apply to LinkedIn, WhatsApp, email, and public LinkedIn posts.
1070
+ These rules apply to LinkedIn messages and invitations, WhatsApp, email, and
1071
+ public LinkedIn posts.
440
1072
 
441
1073
  1. Never send or publish without explicit human approval of the exact
442
- recipient or audience and the exact final text.
443
- 2. Read the exact thread immediately before the action. Check the recipient,
444
- latest inbound message, prior context, and whether the proposed text already
445
- exists as an outbound item.
1074
+ recipient or audience and the exact final text. The only standing-action
1075
+ exception is invitation auto-accept after explicit approval of its exact
1076
+ persistent setting and filters.
1077
+ 2. Read the exact thread immediately before a message action. For invitations,
1078
+ preview the exact target and note or list the exact current invitation.
1079
+ Check the recipient, latest inbound message, prior context, relationship,
1080
+ pending state, and whether the proposed action already exists.
446
1081
  3. Never infer a recipient from a partial name. Resolve duplicate names with
447
1082
  the user.
448
- 4. Never bulk-send, fan out, loop over people, or parallelize actions.
1083
+ 4. Never loop or parallelize single-object send tools. Only the immutable
1084
+ invitation-batch runner may submit more than one target, and only after the
1085
+ separate exact browser approval.
449
1086
  5. Never turn reactions, comments, connections, or exported rows into an
450
1087
  unsolicited outreach list.
451
1088
  6. Send one message at human pace. Let server pacing finish.
@@ -456,9 +1093,19 @@ These rules apply to LinkedIn, WhatsApp, email, and public LinkedIn posts.
456
1093
  9. Confirm delivery by reading the thread after the action.
457
1094
  10. Account health outranks throughput and task completion.
458
1095
 
459
- The hosted backend defaults to 20 action attempts per authenticated user per
460
- UTC day, shared across message sends, email sends, and post publishing. A
461
- deployment can configure a different cap. Never promise a particular remaining
1096
+ The hosted backend defaults to 20 LinkedIn action attempts per stable logical
1097
+ sender per UTC day, shared across messages, invitations, invitation
1098
+ maintenance, and post publishing. Invitation sends also use the default
1099
+ 100-attempt weekly policy.
1100
+
1101
+ WhatsApp and email share ONE separate daily bucket, and it is independent of
1102
+ the LinkedIn one: a WhatsApp message never spends LinkedIn capacity and a
1103
+ LinkedIn message never spends WhatsApp capacity.
1104
+ Deletes have a third bucket again, so a `429` on a delete never means you are
1105
+ out of sends. Read which cap the `429` names before you conclude anything about
1106
+ another channel. `sd_budget_status` is authoritative for the LinkedIn one.
1107
+
1108
+ A deployment can configure different caps. Never promise a particular remaining
462
1109
  allowance until the response reports `rate_limit.limit` and `remaining`.
463
1110
 
464
1111
  ## Worked flows
@@ -529,7 +1176,9 @@ User: "Who engaged with my last LinkedIn post, and what should I do?"
529
1176
  Do not invent profile facts absent from the result.
530
1177
  6. If the user asks to message one person, locate the exact existing chat,
531
1178
  read it, draft a contextual message, obtain exact approval, re-read, and
532
- send once. SignalDash cannot invite or mass-message reactors.
1179
+ send once. If the user explicitly asks to invite one exact reactor, resolve
1180
+ the exact provider ID, run the invitation preview, obtain exact approval,
1181
+ and send once. Engagement never authorizes an invitation or a loop.
533
1182
 
534
1183
  ### Flow 3: find and reply to a WhatsApp thread
535
1184
 
@@ -572,7 +1221,130 @@ User: "Find my WhatsApp thread with Sara and reply that 16:30 works."
572
1221
  8. Read the chat again and confirm the text appears once as an outbound
573
1222
  message.
574
1223
 
575
- ### Flow 4: export LinkedIn connections
1224
+ If the user then asks to take that message back:
1225
+
1226
+ 1. Read the chat again and quote back the exact message you are about to
1227
+ remove, with its `message_id`, and get explicit approval for that exact
1228
+ message. A delete cannot be undone and there is no draft state to review.
1229
+ 2. Delete once:
1230
+
1231
+ ```text
1232
+ wa_delete_message({"chat_id":"chat_wa_91b2","message_id":"msg_wa_5c71"})
1233
+ ```
1234
+
1235
+ 3. Read the chat again. WhatsApp enforces its own time and role limits and can
1236
+ answer a delete successfully without removing anything, so a 200 is not
1237
+ proof. Only the re-read is.
1238
+ 4. Never delete a message the user did not name. `403 message_not_own` means
1239
+ the message is the other person's and cannot be removed by anyone,
1240
+ including a group admin. `409 duplicate_delete` means SignalDash already
1241
+ recorded this exact delete; do not retry it, re-read instead.
1242
+
1243
+ For several messages, pass them to `wa_delete_messages` in one call rather than
1244
+ looping `wa_delete_message` yourself: it paces them and never runs two at once.
1245
+ Read the per-entry results, and resend only the entries marked
1246
+ `code:batch_deadline`, which were never attempted.
1247
+
1248
+ ### Flow 4: send, accept, or withdraw one LinkedIn invitation
1249
+
1250
+ For a new invitation:
1251
+
1252
+ 1. Resolve one exact `provider_id`. A display name or fuzzy match is
1253
+ insufficient.
1254
+ 2. Preview without confirmation:
1255
+
1256
+ ```text
1257
+ li_send_invitation({
1258
+ "provider_id":"ACoAAExactMember",
1259
+ "note":"Hi Amina, I enjoyed your post on agent safety."
1260
+ })
1261
+ ```
1262
+
1263
+ 3. Show the server-confirmed sender, target, exact note, and character count.
1264
+ 4. Obtain approval for that exact target and note.
1265
+ 5. Repeat the identical payload once with `"confirm":true`.
1266
+ 6. Stop on relationship, existing-conversation, incomplete-state,
1267
+ pending-invitation, cap, warning, lock, or `outcome_unknown` errors. Never
1268
+ retry an ambiguous result.
1269
+
1270
+ For received or sent invitation maintenance:
1271
+
1272
+ 1. Call `li_invitations_received` or `li_invitations_sent`.
1273
+ 2. Resolve the exact invitation ID and show the exact person and action.
1274
+ 3. Obtain approval.
1275
+ 4. Call `li_accept_invitation` or `li_withdraw_invitation` once with that ID
1276
+ and `"confirm":true`.
1277
+ 5. List again to verify that the invitation is no longer pending.
1278
+
1279
+ Never loop over the returned page. Incoming acceptance and stale withdrawal
1280
+ are human-approved, single-object operations only.
1281
+
1282
+ ### Flow 4b: enable and inspect invitation auto-accept
1283
+
1284
+ User: "Auto-accept LinkedIn invitations from these exact public IDs when their
1285
+ description contains Founder."
1286
+
1287
+ 1. Call `sd_settings_get({})`, then show the exact persistent setting:
1288
+
1289
+ ```text
1290
+ Enabled: true
1291
+ Public identifiers: amina-rahman, marco-silva
1292
+ Description keywords: Founder
1293
+ Matching rule: exact public ID AND description keyword
1294
+ ```
1295
+
1296
+ 2. Obtain explicit approval, then call once:
1297
+
1298
+ ```text
1299
+ sd_settings_set({
1300
+ "auto_accept_linkedin":true,
1301
+ "auto_accept_linkedin_filters":{
1302
+ "public_identifiers":["amina-rahman","marco-silva"],
1303
+ "description_keywords":["Founder"]
1304
+ },
1305
+ "confirm":true
1306
+ })
1307
+ ```
1308
+
1309
+ 3. Verify with:
1310
+
1311
+ ```text
1312
+ sd_auto_accept_status({})
1313
+ ```
1314
+
1315
+ 4. Report the setting, accepted count today and this week, failed attempts
1316
+ today, both remaining capacities, repeated-error state, and every
1317
+ unparseable invitation. If the worker disabled itself, stop and surface the
1318
+ exact `disabled_reason` and missing fields. Do not silently re-enable it.
1319
+ 5. To disable, obtain explicit approval and call
1320
+ `sd_settings_set({"auto_accept_linkedin":false,"confirm":true})`, then
1321
+ verify both `sd_settings_get({})` and status.
1322
+
1323
+ ### Flow 5: search LinkedIn connections locally
1324
+
1325
+ User: "Find product leaders at Acme in my LinkedIn network."
1326
+
1327
+ 1. Call:
1328
+
1329
+ ```text
1330
+ li_search_connections({
1331
+ "query":"product leader",
1332
+ "filters":{"company":"Acme"},
1333
+ "limit":20
1334
+ })
1335
+ ```
1336
+
1337
+ 2. Confirm the response has `local_only:true` and `provider_requests:0`.
1338
+ 3. Return only the stored profile fields and exact contact-state result. Do not
1339
+ add inferred profile facts or cross-channel identity.
1340
+ 4. If the response says `connections_not_synced` or
1341
+ `connections_sync_pending`, relay its paced-sync instruction. Do not trigger
1342
+ profile reads or discovery as a substitute.
1343
+ 5. Stop after the read-only results. A later action on one exact person starts
1344
+ the complete guarded one-object flow with fresh provider context and
1345
+ approval. Never turn the result page into a send loop.
1346
+
1347
+ ### Flow 6: export LinkedIn connections
576
1348
 
577
1349
  User: "Export my LinkedIn connections to CSV."
578
1350
 
@@ -595,11 +1367,156 @@ User: "Export my LinkedIn connections to CSV."
595
1367
  6. Report the saved path and row count. If the CLI reports a paused or partial
596
1368
  sync, say that the export is partial and rerun later to resume.
597
1369
 
1370
+ ### Flow 7: discover people beyond the stored network
1371
+
1372
+ User: "Find platform engineers at Acme in Berlin beyond my network."
1373
+
1374
+ 1. Call without confirmation:
1375
+
1376
+ ```text
1377
+ li_discover_people({
1378
+ "query":"Platform Engineer",
1379
+ "filters":{"company":"Acme","location":"Berlin"},
1380
+ "limit":5
1381
+ })
1382
+ ```
1383
+
1384
+ 2. If it returns `source:"own_network"`, return those local matches and stop.
1385
+ The paid provider was not called.
1386
+ 3. If it returns `connections_sync_required`, run or resume the paced
1387
+ connections sync and stop. Do not bypass it.
1388
+ 4. If it returns a paid preview, show the exact query, filters, result limit,
1389
+ and `maximum_cost_reserved_usd`. Obtain explicit approval.
1390
+ 5. Repeat the identical payload once with `"confirm":true`.
1391
+ 6. Report only the returned public profile-card fields and exact contact state.
1392
+ State that hidden/unusable and exact already-connected cards were excluded.
1393
+ 7. Stop after the read-only results. Do not enrich profiles, find emails,
1394
+ create a batch, or loop into invitations or messages.
1395
+
1396
+ ### Flow 8: create, review, or cancel one invitation batch
1397
+
1398
+ User: "Invite these two exact LinkedIn profiles with these notes."
1399
+
1400
+ 1. Confirm the human supplied every canonical profile URL, exact inclusion
1401
+ reason, exact optional note, source label, and sender timezone. Ask for any
1402
+ missing value. Do not fill gaps with search or generation.
1403
+ 2. Call:
1404
+
1405
+ ```text
1406
+ li_create_invitation_batch({
1407
+ "source_label":"User-approved event follow-up",
1408
+ "time_zone":"Europe/Berlin",
1409
+ "targets":[
1410
+ {
1411
+ "profile_url":"https://www.linkedin.com/in/amina-rahman/",
1412
+ "inclusion_reason":"The user met Amina at the named event",
1413
+ "note":"Hi Amina, great meeting you at the agent safety meetup."
1414
+ }
1415
+ ]
1416
+ })
1417
+ ```
1418
+
1419
+ 3. Store the exact `batch_id`. Call `li_get_invitation_batch` until its state
1420
+ is `previewed` or terminal. Report every excluded row and reason.
1421
+ 4. Show the sender, source, ordered targets, full profile URLs, reasons, exact
1422
+ notes, reused-copy counts, current limits, timing, expiry, cancellation
1423
+ limit, and residual native-activity race.
1424
+ 5. Relay the exact `approval_url`. Do not fetch, submit, or automate that
1425
+ browser page. The human reauthenticates and selects the desired unchecked
1426
+ rows.
1427
+ 6. Inspect the batch to report `approved`, `running`, or terminal progress.
1428
+ Never interpret an approval-page visit as approval; only server state proves
1429
+ it.
1430
+ 7. For cancellation, inspect immediately, show the exact state and
1431
+ `approval_view_hash`, obtain explicit cancellation approval, then call:
1432
+
1433
+ ```text
1434
+ li_cancel_invitation_batch({
1435
+ "batch_id":"00000000-0000-4000-8000-000000000000",
1436
+ "approval_view_hash":"the exact hash from the fresh read",
1437
+ "confirm":true
1438
+ })
1439
+ ```
1440
+
1441
+ 8. Report that planned targets were cancelled and any executing action was not
1442
+ recalled. Never claim the batch was paused or can resume.
1443
+
1444
+ ### Flow 9: create, approve, and watch one campaign
1445
+
1446
+ User: "Send connection requests to everyone who engaged with my last post and
1447
+ message them when they accept."
1448
+
1449
+ 1. Confirm the exact source (their own post engagers, or an exact list they
1450
+ supply), the sender timezone, and the exact wording of every message. Draft
1451
+ in their voice, two or three short sends for the on-acceptance group, and
1452
+ read them back for approval before creating anything.
1453
+ 2. Call `sd_campaign_create` once, then poll `sd_campaign_preview` until it is
1454
+ `previewed` or terminal.
1455
+ 3. Report the recipient count, every exclusion reason, and the exact message
1456
+ text. Relay the `approval_url` and do not fetch, submit, or automate that
1457
+ page.
1458
+ 4. If the human hands you a `sd-xxxx-xxxx-xxxx` code, call
1459
+ `sd_campaign_approve` with it once. If they approved in the browser instead,
1460
+ just confirm with `sd_campaign_status`.
1461
+ 5. Report progress from `sd_campaign_status` only. Never treat a disappeared
1462
+ invitation as an acceptance, and never claim a message was sent unless the
1463
+ step state says `sent`.
1464
+ 6. For cancellation, inspect immediately, show the state and
1465
+ `approval_view_hash`, obtain approval, then call `sd_campaign_cancel`.
1466
+
1467
+ ### Flow 10: clear a backlog of old pending sent invitations
1468
+
1469
+ User: "I have 1668 pending invitations. Withdraw everything older than a month."
1470
+
1471
+ 1. Before calling anything, ask two questions and wait for the answers:
1472
+ **"Who must NOT be withdrawn?"** and the sender timezone. Do not guess
1473
+ either. If they say "nobody", that is a real answer and you pass `[]`, but
1474
+ read back that nobody will be protected.
1475
+ 2. Tell them, in plain words, before they approve:
1476
+ - this does **not** free up sending capacity, LinkedIn does not lift a
1477
+ sending restriction when you withdraw;
1478
+ - they will **not be able to re-invite these people for up to three weeks**,
1479
+ and say how many people that is;
1480
+ - "older than a month" is bucket-accurate at best, because LinkedIn only
1481
+ exposes labels like "sent 4 months ago", so the count they see may be
1482
+ smaller than they expect and that is deliberate.
1483
+ 3. Call it once:
1484
+
1485
+ ```text
1486
+ sd_withdrawal_batch_create({
1487
+ "account":"linkedin",
1488
+ "older_than_days":90,
1489
+ "time_zone":"Europe/Berlin",
1490
+ "exclude":[
1491
+ {"kind":"display_name","value":"the exact name they gave you"},
1492
+ {"kind":"public_identifier","value":"their-linkedin-slug"}
1493
+ ]
1494
+ })
1495
+ ```
1496
+
1497
+ 4. Poll `sd_withdrawal_batch_status` until `previewed` or terminal. If it comes
1498
+ back `failed_preview` with `exclusion_matched_nothing`, do NOT retry with
1499
+ `allow_unmatched_exclusions:true` on your own initiative: show the human the
1500
+ unmatched entries and ask whether it is a typo. A typo means the person they
1501
+ meant to protect would be withdrawn.
1502
+ 5. Report the count, the exact protected names, the naive versus conservative
1503
+ age counts, and the expected number of working days at 44 a day. Relay the
1504
+ `approval_url` and do not fetch, submit, or automate that page.
1505
+ 6. If the human hands you a `sd-xxxx-xxxx-xxxx` code, call
1506
+ `sd_withdrawal_batch_approve` with it once. Otherwise confirm with
1507
+ `sd_withdrawal_batch_status`.
1508
+ 7. Report progress from `sd_withdrawal_batch_status` only. If `state` is
1509
+ `stopped`, say the `stop_reason` plainly and do not create a replacement
1510
+ sweep until the human decides.
1511
+
598
1512
  ## Read-only requests and drafts
599
1513
 
600
- Read-only work does not require send approval. You can list, read, summarize,
601
- analyze engagement, and create a non-published post draft as requested. Still
602
- use narrow limits, protect private content, and avoid bulk collection.
1514
+ Read-only account work does not require send approval. You can list, read,
1515
+ search the stored connection snapshot, summarize, analyze engagement, and
1516
+ create a non-published post draft as requested. Paid discovery separately
1517
+ requires approval of its exact preview and maximum reserved cost. Still use
1518
+ narrow limits, protect private content, and avoid bulk collection. Search and
1519
+ discovery results do not authorize an outbound action.
603
1520
 
604
1521
  For every draft, label it clearly as a draft. Never present a suggested reply
605
1522
  as sent. Never present `{"drafted":true,"published":false}` as a published post.
@@ -628,11 +1545,65 @@ Before reporting completion, verify:
628
1545
  - The skill is persisted in a discovered skill directory.
629
1546
  - `status` proves the required provider is connected.
630
1547
  - The exact chat, thread, post, or export path was resolved.
1548
+ - A connection search reported `local_only:true`, made zero provider requests,
1549
+ and did not authorize an outbound action.
1550
+ - A paid discovery had a completed connection snapshot, returned no own-network
1551
+ matches, used an unexpired exact preview and approval, made at most one
1552
+ serialized provider request, reported its cost budget, and remained planning
1553
+ evidence only.
631
1554
  - Every send used a fresh exact-thread read and exact human approval.
1555
+ - Every invitation send used an exact server preview and exact approval.
1556
+ - Every invitation accept or withdrawal used a fresh exact invitation-list
1557
+ read and exact approval.
1558
+ - Invitation auto-accept was explicitly enabled for the exact user, preserved
1559
+ both the dedicated and shared caps, stopped on repeated errors, and exposed
1560
+ every unparseable invitation through `sd_auto_accept_status`.
1561
+ - Every invitation batch used only exact user-supplied canonical profile URLs,
1562
+ reasons, and notes; exposed every target; used the separate browser approval;
1563
+ and never approved through MCP.
1564
+ - Every batch cancel followed a fresh exact batch read and stated that an
1565
+ executing action cannot be recalled.
632
1566
  - No duplicate, bulk, parallel, warning, 403, or 429 path was bypassed.
1567
+ - No sender lock or `outcome_unknown` result was retried.
633
1568
  - A send was confirmed by a post-send read.
634
- - A post draft remained unpublished unless `publish:true` was explicitly
635
- approved.
1569
+ - A post draft remained unpublished and unscheduled unless `publish:true` was
1570
+ explicitly approved. Every scheduled post was read back with its exact UTC
1571
+ instant, mention count, attachment count, first comment, and durable states.
636
1572
  - An export ended with `+ saved` and a non-empty CSV.
637
1573
 
638
1574
  If any item is unverified, state exactly what remains incomplete.
1575
+
1576
+ ## How to write the message (this is where agents fail hardest)
1577
+
1578
+ Agents pad. Padding is the clearest tell that a human did not write it, and on
1579
+ LinkedIn or WhatsApp it gets ignored. Less is more, always.
1580
+
1581
+ - **Short.** A reply is usually 1-3 sentences. If they wrote one line, reply
1582
+ with one line. Match the length and register of the thread.
1583
+ - **One idea per message.** Do not stack context, ask and pleasantry into one
1584
+ block. Split into 2-3 short consecutive sends instead of one paragraph.
1585
+ - **No preamble, no summary-back.** Never "I hope this finds you well", never
1586
+ restate what they just said, never a formal sign-off in a chat.
1587
+ - **No em dashes.** Use commas, periods, colons.
1588
+ - **No hype filler.** Cut "excited to", "reaching out", "just wanted to",
1589
+ "circling back", "leverage", "synergies", and any eager closer.
1590
+ - **Their language.** German thread stays German, with real umlauts (für, not
1591
+ fuer). Never translate their language away.
1592
+ - **Read the thread first, then sound like the user.** Their own recent messages
1593
+ in that thread are the style reference. Copy that register, not a template.
1594
+ - **Call `sd_voice_profile(channel)` before drafting.** It returns this exact
1595
+ user's own hard length stats (median/p75/p90) and verbatim redacted
1596
+ exemplars for that one channel, mined from their real sent messages. Match
1597
+ the returned numbers and rhythm, not a generic idea of "their style" from
1598
+ the thread alone.
1599
+
1600
+ Before/after, same intent:
1601
+
1602
+ > Bad: "Hi Alex, I hope you're doing well! Thanks so much for reaching out
1603
+ > about scheduling a call. I'd be delighted to connect and would love to explore
1604
+ > how we might be able to work together. Please let me know what times work best
1605
+ > for you and I'll do my best to accommodate your schedule."
1606
+
1607
+ > Good: "hey Alex, sure. here's my link: [cal]"
1608
+
1609
+ If a draft is longer than the thread's own messages, cut it before showing it.