@floomhq/signaldash 0.1.0 → 0.3.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.
package/README.md CHANGED
@@ -2,59 +2,52 @@
2
2
 
3
3
  Secure LinkedIn and WhatsApp access for AI agents.
4
4
 
5
- `@floomhq/signaldash` connects your own accounts through Unipile and exposes
6
- six account-scoped MCP tools. There is no email integration, CRM cockpit,
7
- signals engine, campaigns layer, sync database, or web UI in this package.
5
+ `@floomhq/signaldash` connects your accounts through the hosted SignalDash
6
+ service and exposes six account-scoped MCP tools. The CLI never receives or
7
+ stores the server's Unipile access key.
8
8
 
9
- ## Connect
9
+ ## Quickstart
10
10
 
11
- SignalDash requires Node.js 20+ and a Unipile account. In the
12
- [Unipile dashboard](https://developer.unipile.com/docs/getting-started), copy
13
- your DSN and generate an Access Token. The API URL is your DSN followed by
14
- `/api/v1`.
11
+ You need Node.js 20 or newer and a single-use SignalDash invite code. Ask the
12
+ SignalDash administrator for an invite code, then run:
15
13
 
16
14
  ```bash
17
- export SIGNALDASH_UNIPILE_BASE="https://apiXXX.unipile.com:XXXXX/api/v1"
18
- export SIGNALDASH_UNIPILE_KEY="..."
19
-
15
+ npx -y @floomhq/signaldash login <invite-code>
20
16
  npx -y @floomhq/signaldash connect linkedin
21
17
  npx -y @floomhq/signaldash connect whatsapp
22
18
  ```
23
19
 
24
- Each command creates a short-lived
25
- [Unipile hosted-auth](https://developer.unipile.com/docs/hosted-auth) URL.
26
- Open it to complete LinkedIn login or scan the live WhatsApp QR, then return
27
- to the terminal. SignalDash verifies the account through `GET /accounts` and
28
- stores its account ID.
29
-
30
- Without environment variables, run either command in a terminal. SignalDash
31
- prompts for the API URL and hides API-key input.
32
-
33
- Credentials live under `~/.signaldash` by default:
20
+ Each connect command prints a short-lived hosted-auth URL and waits for the
21
+ connection to finish. Open the LinkedIn URL and sign in, or open the WhatsApp
22
+ URL and scan the live QR code. The CLI prints `Connected linkedin` or
23
+ `Connected whatsapp` when the account is ready.
34
24
 
35
- - `device.key`: local 256-bit encryption key, mode `0600`
36
- - `secrets.enc`: AES-256-GCM credential envelope, mode `0600`
37
- - `config.json`: non-secret account references, mode `0600`
25
+ The invite code is single-use. Login stores a SignalDash user token in
26
+ `~/.signaldash/config.json`; the Unipile access key remains on the SignalDash
27
+ server. Set `SIGNALDASH_HOME` to keep the local config in another directory.
38
28
 
39
- The Unipile key is never written to config, printed, returned by an MCP tool,
40
- or passed to an MCP caller. Set `SIGNALDASH_HOME` to use another workspace.
41
-
42
- ## MCP
29
+ If automatic account detection times out, the CLI prints the manual claim
30
+ command for the provider:
43
31
 
44
32
  ```bash
45
- npx -y @floomhq/signaldash mcp
33
+ npx -y @floomhq/signaldash connect linkedin claim <account_id>
46
34
  ```
47
35
 
48
- The command starts a stdio MCP server. Its one-line registration hint is
49
- written to stderr so stdout remains valid JSON-RPC.
36
+ ## Add SignalDash to your agent
37
+
38
+ ### Claude Code
50
39
 
51
- Claude Code:
40
+ Paste this command into a terminal:
52
41
 
53
42
  ```bash
54
43
  claude mcp add signaldash -- npx -y @floomhq/signaldash mcp
55
44
  ```
56
45
 
57
- Cursor, `.cursor/mcp.json`:
46
+ ### Cursor
47
+
48
+ Create or update `.cursor/mcp.json` in the project. Merge the `signaldash`
49
+ entry into an existing `mcpServers` object when the file already contains
50
+ other servers:
58
51
 
59
52
  ```json
60
53
  {
@@ -67,49 +60,57 @@ Cursor, `.cursor/mcp.json`:
67
60
  }
68
61
  ```
69
62
 
70
- The entire MCP surface is:
63
+ Both registrations start the same stdio server:
64
+
65
+ ```bash
66
+ npx -y @floomhq/signaldash mcp
67
+ ```
68
+
69
+ The MCP server uses the user token created by `login`. It cannot access a
70
+ LinkedIn or WhatsApp account until that channel has been connected for the
71
+ same logged-in user.
72
+
73
+ ## Give the agent the safety skill
74
+
75
+ Before allowing the agent to send messages, give it the bundled
76
+ [`signaldash-safe-usage` skill](skills/signaldash-safe-usage/SKILL.md). The
77
+ skill requires the agent to resolve the exact chat, read recent thread
78
+ history, check for duplicates, and obtain approval when the recipient or text
79
+ has not already been approved.
80
+
81
+ The MCP registration does not automatically load the skill yet. Copy
82
+ `skills/signaldash-safe-usage/SKILL.md` into the agent's skills directory or
83
+ attach its contents to the agent instructions. Keep this requirement in both
84
+ Claude Code and Cursor.
85
+
86
+ ## MCP tools
87
+
88
+ SignalDash exposes:
71
89
 
72
90
  - `li_list_chats`
73
- - `li_read_messages(chat)`
74
- - `li_send_message(chat, text)`
91
+ - `li_read_messages(chat_id)`
92
+ - `li_send_message(chat_id, text)`
75
93
  - `wa_list_chats`
76
- - `wa_read_messages(chat)`
77
- - `wa_send_message(chat, text)`
78
-
79
- Every read and send verifies that the chat belongs to the configured account.
80
- List tools use chat-list endpoints only and never fetch profiles.
81
-
82
- ## Safety is a feature
83
-
84
- The package includes
85
- [`skills/signaldash-safe-usage/SKILL.md`](skills/signaldash-safe-usage/SKILL.md).
86
- Install or give that skill to the agent using SignalDash.
87
-
88
- The runtime also enforces non-configurable safeguards:
89
-
90
- - LinkedIn: 18 send attempts per account per UTC day; 45 to 90 seconds
91
- between sends.
92
- - WhatsApp: 30 send attempts per account per UTC day; 15 to 35 seconds
93
- between sends.
94
- - The first send also receives randomized pre-send jitter.
95
- - Every send re-reads the recent thread and blocks an exact duplicate.
96
- - Provider warning headers, checkpoints, restrictions, HTTP 403, and HTTP 429
97
- fail closed and persistently disable sends for that account.
98
- - A file lock serializes sends across concurrent MCP processes.
99
-
100
- LinkedIn invitation ceilings vary by account and often appear around 100 per
101
- week. SignalDash exposes no invitation tool. Keep invitation automation out
102
- of agent loops.
103
-
104
- Avoid bulk profile reads, copied message bursts, cold-account automation, and
105
- repeated retries after a checkpoint. Let the agent act slowly and human-like.
106
- On WhatsApp, use established accounts, keep the phone online, and avoid bulk
107
- or unsolicited messaging.
108
-
109
- After a provider warning, stop. Review the provider account manually and wait
110
- for it to return to good standing. The warning lock is recorded in
111
- `~/.signaldash/safety/send-state.json`; only a human may clear the relevant
112
- account entry after resolving the restriction.
94
+ - `wa_read_messages(chat_id)`
95
+ - `wa_send_message(chat_id, text)`
96
+
97
+ Every operation runs through the hosted SignalDash backend. Agents never
98
+ receive the Unipile access key.
99
+
100
+ ## Safety limits
101
+
102
+ SignalDash enforces a daily send cap. The safe-usage skill adds the human
103
+ workflow around that runtime control:
104
+
105
+ - Read the exact thread before every send.
106
+ - Never infer a recipient from a partial name.
107
+ - Never send a duplicate or retry an ambiguous timeout without re-reading.
108
+ - Do not parallelize sends or work around a rate limit.
109
+ - Stop on a provider warning, checkpoint, restriction, HTTP 403, or HTTP 429.
110
+ - Avoid bulk profile reads, copied message bursts, cold-account automation,
111
+ and unsolicited WhatsApp messaging.
112
+
113
+ SignalDash exposes no LinkedIn invitation tool.
113
114
 
114
115
  ## Development
115
116
 
package/bin/sd.mjs CHANGED
@@ -3,48 +3,109 @@
3
3
  // bearer token. Never holds a Unipile key, never calls Unipile directly. The MCP
4
4
  // tools an agent uses all proxy through the backend, so agents access channels
5
5
  // THROUGH SignalDash, not around it.
6
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, realpathSync, writeFileSync, mkdirSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { createInterface } from "node:readline";
9
+ import { fileURLToPath } from "node:url";
9
10
 
10
- const CFG_DIR = process.env.SIGNALDASH_HOME || `${homedir()}/.signaldash`;
11
- const CFG = `${CFG_DIR}/config.json`;
12
11
  const DEFAULT_BACKEND = process.env.SIGNALDASH_BACKEND || "https://signaldash-api.floom.dev";
13
12
 
14
- function loadCfg() { try { return JSON.parse(readFileSync(CFG, "utf8")); } catch { return {}; } }
15
- function saveCfg(c) { mkdirSync(CFG_DIR, { recursive: true }); writeFileSync(CFG, JSON.stringify(c, null, 2), { mode: 0o600 }); }
13
+ function configPaths() {
14
+ const directory = process.env.SIGNALDASH_HOME || `${homedir()}/.signaldash`;
15
+ return { directory, file: `${directory}/config.json` };
16
+ }
17
+ function loadCfg() {
18
+ try {
19
+ return JSON.parse(readFileSync(configPaths().file, "utf8"));
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+ function saveCfg(c) {
25
+ const config = configPaths();
26
+ mkdirSync(config.directory, { recursive: true });
27
+ writeFileSync(config.file, JSON.stringify(c, null, 2), { mode: 0o600 });
28
+ }
16
29
 
17
- async function api(path, body, { auth = true } = {}) {
30
+ async function api(path, body, { auth = true, method = "POST" } = {}) {
18
31
  const cfg = loadCfg();
19
32
  const backend = cfg.backend || DEFAULT_BACKEND;
20
33
  const r = await fetch(backend + path, {
21
- method: "POST",
34
+ method,
22
35
  headers: { "Content-Type": "application/json", ...(auth && cfg.token ? { Authorization: `Bearer ${cfg.token}` } : {}) },
23
- body: JSON.stringify(body || {}),
36
+ ...(method === "GET" ? {} : { body: JSON.stringify(body || {}) }),
24
37
  });
25
38
  return { status: r.status, json: await r.json().catch(() => ({})) };
26
39
  }
27
40
 
28
- async function cmdLogin(code, backend) {
41
+ const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
42
+
43
+ function positiveMilliseconds(name, fallback) {
44
+ const raw = process.env[name];
45
+ if (raw === undefined) return fallback;
46
+ const parsed = Number(raw);
47
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
48
+ }
49
+
50
+ export async function cmdLogin(code, backend, dependencies = {}) {
51
+ const request = dependencies.request || api;
52
+ const log = dependencies.log || console.log;
53
+ const error = dependencies.error || console.error;
29
54
  const cfg = loadCfg();
30
55
  cfg.backend = backend || cfg.backend || DEFAULT_BACKEND;
31
56
  saveCfg(cfg);
32
- const r = await api("/login", { code }, { auth: false });
33
- if (r.status !== 200) { console.error("login failed:", r.json.error || r.status); process.exit(1); }
57
+ const r = await request("/login", { code }, { auth: false });
58
+ if (r.status !== 200) { error("login failed:", r.json.error || r.status); process.exitCode = 1; return; }
34
59
  cfg.token = r.json.token; saveCfg(cfg);
35
- console.log("Logged in to SignalDash. Token stored (no channel keys on your side).");
60
+ log("Logged in to SignalDash. Token stored (no channel keys on your side).");
36
61
  }
37
62
 
38
- async function cmdConnect(provider) {
39
- if (!["linkedin", "whatsapp"].includes(provider)) { console.error("usage: sd connect linkedin|whatsapp"); process.exit(1); }
40
- const r = await api(`/connect/${provider}`, {});
41
- if (r.status >= 300) { console.error("connect failed:", r.json.error || r.status); process.exit(1); }
42
- console.log(`Open this to connect your ${provider}:\n\n ${r.json.url}\n`);
43
- console.log(`After authenticating, run:\n sd connect ${provider} claim <account_id>`);
63
+ export async function cmdConnect(provider, dependencies = {}) {
64
+ const request = dependencies.request || api;
65
+ const sleep = dependencies.wait || wait;
66
+ const now = dependencies.now || Date.now;
67
+ const log = dependencies.log || console.log;
68
+ const error = dependencies.error || console.error;
69
+ if (!["linkedin", "whatsapp"].includes(provider)) {
70
+ error("usage: signaldash connect linkedin|whatsapp");
71
+ process.exitCode = 1;
72
+ return;
73
+ }
74
+ const r = await request(`/connect/${provider}`, {});
75
+ if (r.status >= 300) {
76
+ error("connect failed:", r.json.error || r.status);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ log(`Open this to connect your ${provider}:\n\n ${r.json.url}\n`);
81
+ log("Waiting for authentication to complete...");
82
+
83
+ const timeoutMs = positiveMilliseconds("SIGNALDASH_CONNECT_TIMEOUT_MS", 5 * 60 * 1000);
84
+ const pollMs = positiveMilliseconds("SIGNALDASH_CONNECT_POLL_MS", 2000);
85
+ const deadline = now() + timeoutMs;
86
+ while (now() < deadline) {
87
+ const status = await request(`/connect/${provider}/status`, undefined, { method: "GET" });
88
+ if (status.status === 200 && status.json.connected) {
89
+ log(`Connected ${provider}${status.json.name ? `: ${status.json.name}` : ""}`);
90
+ return;
91
+ }
92
+ if (status.status !== 202) {
93
+ error("connect status failed:", status.json.error || status.status);
94
+ process.exitCode = 1;
95
+ return;
96
+ }
97
+ await sleep(Math.min(pollMs, Math.max(0, deadline - now())));
98
+ }
99
+
100
+ error(`Connection timed out. Manual fallback:\n signaldash connect ${provider} claim <account_id>`);
101
+ process.exitCode = 1;
44
102
  }
45
- async function cmdClaim(provider, accountId) {
46
- const r = await api(`/connect/${provider}/claim`, { account_id: accountId });
47
- console.log(r.status === 200 ? `Connected ${provider}: ${r.json.name}` : `claim failed: ${r.json.error}`);
103
+ export async function cmdClaim(provider, accountId, dependencies = {}) {
104
+ const request = dependencies.request || api;
105
+ const log = dependencies.log || console.log;
106
+ const r = await request(`/connect/${provider}/claim`, { account_id: accountId });
107
+ log(r.status === 200 ? `Connected ${provider}: ${r.json.name}` : `claim failed: ${r.json.error}`);
108
+ if (r.status !== 200) process.exitCode = 1;
48
109
  }
49
110
 
50
111
  // ---- MCP (stdio). Every tool proxies through the backend with the token. ----
@@ -60,9 +121,12 @@ function mcpTool(name) {
60
121
  return { name, description: TOOLS.find(t => t.name === name).description,
61
122
  inputSchema: { type: "object", properties: { chat_id: { type: "string" }, text: { type: "string" }, limit: { type: "number" } } } };
62
123
  }
63
- async function runMcp() {
64
- const rl = createInterface({ input: process.stdin });
65
- const reply = (id, result, error) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error } : { result }) }) + "\n");
124
+ export async function runMcp(dependencies = {}) {
125
+ const input = dependencies.input || process.stdin;
126
+ const output = dependencies.output || process.stdout;
127
+ const request = dependencies.request || api;
128
+ const rl = createInterface({ input });
129
+ const reply = (id, result, error) => output.write(JSON.stringify({ jsonrpc: "2.0", id, ...(error ? { error } : { result }) }) + "\n");
66
130
  for await (const line of rl) {
67
131
  let msg; try { msg = JSON.parse(line); } catch { continue; }
68
132
  const { id, method, params } = msg;
@@ -71,15 +135,41 @@ async function runMcp() {
71
135
  else if (method === "tools/call") {
72
136
  const t = TOOLS.find(x => x.name === params.name);
73
137
  if (!t) { reply(id, null, { code: -32601, message: "unknown tool" }); continue; }
74
- const r = await api(`/${t.ch}/${t.action}`, params.arguments || {});
138
+ const r = await request(`/${t.ch}/${t.action}`, params.arguments || {});
75
139
  reply(id, { content: [{ type: "text", text: JSON.stringify(r.json) }], isError: r.status >= 300 });
76
140
  } else if (id !== undefined) reply(id, {});
77
141
  }
78
142
  }
79
143
 
80
- const [cmd, a, b, c] = process.argv.slice(2);
81
- if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined);
82
- else if (cmd === "connect" && b === "claim") await cmdClaim(a, c);
83
- else if (cmd === "connect") await cmdConnect(a);
84
- else if (cmd === "mcp") await runMcp();
85
- else console.log(`SignalDash — secure LinkedIn + WhatsApp access for your agent.\n\n sd login <invite-code> [--backend URL]\n sd connect linkedin|whatsapp\n sd connect linkedin|whatsapp claim <account_id>\n sd mcp\n\nThe agent reaches channels only through SignalDash. No keys on your machine.`);
144
+
145
+ export async function cmdSkill(dependencies = {}) {
146
+ const log = dependencies.log || console.log;
147
+ const { mkdirSync: mk, writeFileSync: wf, readFileSync: rf, existsSync: ex } = await import("node:fs");
148
+ const { dirname, join } = await import("node:path");
149
+ const here = dirname(fileURLToPath(import.meta.url));
150
+ const src = join(here, "..", "skills", "signaldash", "SKILL.md");
151
+ if (!ex(src)) { log("skill file not found in package"); process.exitCode = 1; return; }
152
+ const dest = join(homedir(), ".claude", "skills", "signaldash");
153
+ mk(dest, { recursive: true });
154
+ wf(join(dest, "SKILL.md"), rf(src, "utf8"));
155
+ log(`Installed the SignalDash skill to ${dest}/SKILL.md`);
156
+ log("Your agent now knows how to use LinkedIn + WhatsApp safely through SignalDash.");
157
+ }
158
+
159
+ export async function main(argv = process.argv.slice(2), dependencies = {}) {
160
+ const [cmd, a, b, c] = argv;
161
+ const log = dependencies.log || console.log;
162
+ if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined, dependencies);
163
+ else if (cmd === "connect" && b === "claim") await cmdClaim(a, c, dependencies);
164
+ else if (cmd === "connect") await cmdConnect(a, dependencies);
165
+ else if (cmd === "mcp") await runMcp(dependencies);
166
+ else if (cmd === "skill") await cmdSkill(dependencies);
167
+ else log(`SignalDash — secure LinkedIn + WhatsApp access for your agent.\n\n signaldash login <invite-code> [--backend URL]\n signaldash connect linkedin|whatsapp\n signaldash connect linkedin|whatsapp claim <account_id>\n signaldash mcp\n signaldash skill install the agent skill\n\nThe agent reaches channels only through SignalDash. No keys on your machine.`);
168
+ }
169
+
170
+ if (
171
+ process.argv[1] &&
172
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
173
+ ) {
174
+ await main();
175
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/signaldash",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Secure LinkedIn and WhatsApp MCP access for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "node --test",
21
- "check": "node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
21
+ "check": "node --check bin/sd.mjs && node --check server/server.cjs && node --check bin/signaldash.js && node --check lib/cli.js && node --check lib/mcp.js && node --check lib/rate-guard.js && node --check lib/secrets.js && node --check lib/unipile.js"
22
22
  },
23
23
  "keywords": [
24
24
  "mcp",
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: signaldash
3
+ description: Give this agent secure access to the user's LinkedIn and WhatsApp — list chats, read threads, and send messages — through SignalDash. Use whenever the user asks to check, read, triage, reply to, or send LinkedIn/WhatsApp messages. Also covers first-time setup.
4
+ ---
5
+
6
+ # SignalDash — LinkedIn + WhatsApp for this agent
7
+
8
+ SignalDash is the gate between you and the user's real messaging accounts. The
9
+ user's channel credentials live on the SignalDash server, never on their machine
10
+ and never in your context. You act through SignalDash tools only.
11
+
12
+ ## Setup (only if the tools are not available yet)
13
+
14
+ The user needs an invite code from Federico, then:
15
+
16
+ ```bash
17
+ npx @floomhq/signaldash login <invite-code>
18
+ npx @floomhq/signaldash connect linkedin # opens a link; user authenticates; auto-detects
19
+ npx @floomhq/signaldash connect whatsapp # same, QR scan from WhatsApp > Linked Devices
20
+ claude mcp add signaldash -- npx -y @floomhq/signaldash mcp
21
+ ```
22
+
23
+ Cursor: add to `.cursor/mcp.json` →
24
+ `{"mcpServers":{"signaldash":{"command":"npx","args":["-y","@floomhq/signaldash","mcp"]}}}`
25
+
26
+ ## Tools
27
+
28
+ | Tool | What it does |
29
+ |---|---|
30
+ | `li_list_chats` | list the user's LinkedIn chats |
31
+ | `li_read_messages` | read a LinkedIn thread (`chat_id`) |
32
+ | `li_send_message` | send a LinkedIn message (`chat_id`, `text`) |
33
+ | `wa_list_chats` | list the user's WhatsApp chats |
34
+ | `wa_read_messages` | read a WhatsApp thread (`chat_id`) |
35
+ | `wa_send_message` | send a WhatsApp message (`chat_id`, `text`) |
36
+
37
+ ## The rules you MUST follow
38
+
39
+ These protect the user's accounts from being restricted or banned, and protect
40
+ them from you sending something wrong. They are not optional.
41
+
42
+ 1. **Never send without the user's explicit approval of the exact recipient and
43
+ exact text.** Draft it, show it, wait. Discussion is not approval.
44
+ 2. **Read the thread before you send.** Always `*_read_messages` on that exact
45
+ chat first: check who it is, what they last said, and whether this was
46
+ already sent. Never send blind. Never double-send.
47
+ 3. **Never bulk.** No mass messaging, no "message everyone who…", no bulk
48
+ profile fetching. LinkedIn restricts accounts for exactly this. Hard ceiling
49
+ is roughly 15-20 LinkedIn sends per day; the server enforces a daily cap and
50
+ spacing, do not try to work around it or parallelize sends.
51
+ 4. **One message at a time, human pace.** Let the server's pacing do its work.
52
+ 5. **Stop on any warning.** A checkpoint, restriction, "unusual activity"
53
+ prompt, 403 or 429 means halt all activity on that account and tell the user.
54
+ Do not retry.
55
+ 6. **Personalize.** Read the thread and write a specific reply. Templated
56
+ identical messages get reported as spam.
57
+
58
+ ## Good vs bad requests
59
+
60
+ Good: "list my unread LinkedIn chats and draft replies for my approval" ·
61
+ "read the thread with Mircea and suggest an answer" · "what came in on WhatsApp
62
+ from people I've spoken to before?"
63
+
64
+ Bad (refuse or push back): "connection-request everyone who liked my post" ·
65
+ "message all my connections about X" · "pull every profile in my network".
66
+
67
+ Rule of thumb: if a human could not plausibly do it by hand in a day, do not do
68
+ it. Account health beats throughput.