@floomhq/signaldash 0.1.0 → 0.2.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 +74 -73
- package/bin/sd.mjs +105 -31
- package/package.json +2 -2
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
|
|
6
|
-
six account-scoped MCP tools.
|
|
7
|
-
|
|
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
|
-
##
|
|
9
|
+
## Quickstart
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
33
|
+
npx -y @floomhq/signaldash connect linkedin claim <account_id>
|
|
46
34
|
```
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
## Add SignalDash to your agent
|
|
37
|
+
|
|
38
|
+
### Claude Code
|
|
50
39
|
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
74
|
-
- `li_send_message(
|
|
91
|
+
- `li_read_messages(chat_id)`
|
|
92
|
+
- `li_send_message(chat_id, text)`
|
|
75
93
|
- `wa_list_chats`
|
|
76
|
-
- `wa_read_messages(
|
|
77
|
-
- `wa_send_message(
|
|
78
|
-
|
|
79
|
-
Every
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
## Safety
|
|
83
|
-
|
|
84
|
-
The
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
-
|
|
91
|
-
|
|
92
|
-
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
if (r.status !== 200) {
|
|
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
|
-
|
|
60
|
+
log("Logged in to SignalDash. Token stored (no channel keys on your side).");
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
async function cmdConnect(provider) {
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
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
|
|
65
|
-
const
|
|
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,25 @@ 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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
else if (cmd === "
|
|
85
|
-
else
|
|
144
|
+
export async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
145
|
+
const [cmd, a, b, c] = argv;
|
|
146
|
+
const log = dependencies.log || console.log;
|
|
147
|
+
if (cmd === "login") await cmdLogin(a, b === "--backend" ? c : undefined, dependencies);
|
|
148
|
+
else if (cmd === "connect" && b === "claim") await cmdClaim(a, c, dependencies);
|
|
149
|
+
else if (cmd === "connect") await cmdConnect(a, dependencies);
|
|
150
|
+
else if (cmd === "mcp") await runMcp(dependencies);
|
|
151
|
+
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\nThe agent reaches channels only through SignalDash. No keys on your machine.`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
process.argv[1] &&
|
|
156
|
+
realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
157
|
+
) {
|
|
158
|
+
await main();
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@floomhq/signaldash",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|