@sellable/install 0.1.310 → 0.1.311
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/HERMES-SLACK-PROFILE-SCOPING.md +139 -0
- package/README.md +32 -20
- package/bin/sellable-install.mjs +177 -170
- package/lib/runtime-verify.mjs +9 -0
- package/package.json +3 -2
- package/skill-templates/refresh-sender-engagement.md +25 -29
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Hermes Slack Profile Scoping
|
|
2
|
+
|
|
3
|
+
Use this when creating or updating customer Hermes profiles that need Slack
|
|
4
|
+
access.
|
|
5
|
+
|
|
6
|
+
## Default Rule
|
|
7
|
+
|
|
8
|
+
Each Slack-enabled customer profile should have its own Slack app installation:
|
|
9
|
+
|
|
10
|
+
- one profile-local `SLACK_BOT_TOKEN`
|
|
11
|
+
- one profile-local `SLACK_APP_TOKEN`
|
|
12
|
+
- one `SLACK_HOME_CHANNEL`
|
|
13
|
+
- one fail-closed `SLACK_ALLOWED_CHANNELS` allowlist
|
|
14
|
+
|
|
15
|
+
The bot should be invited only to the customer's managed channel, normally
|
|
16
|
+
`sellable-{customerSlug}`.
|
|
17
|
+
|
|
18
|
+
Shared Slack credentials can work for outbound-only smoke tests, but they are a
|
|
19
|
+
weaker isolation model. Do not run multiple active Socket Mode listeners against
|
|
20
|
+
the same Slack app token unless there is a separate dispatcher architecture that
|
|
21
|
+
routes events to profiles.
|
|
22
|
+
|
|
23
|
+
The 2026-07-06 Hostinger POC proved the native Hermes listener for the
|
|
24
|
+
`sellable-admin` profile with the existing `Sellable Reply Bot` app in
|
|
25
|
+
`#team-chat`: a human mention produced a new Hermes session and a threaded bot
|
|
26
|
+
reply. Treat that as proof that the gateway/listener path works for one owning
|
|
27
|
+
profile, not as approval for multiple active customer profiles sharing one app
|
|
28
|
+
token.
|
|
29
|
+
|
|
30
|
+
The same day, the `acme` profile reused the same redacted Slack token
|
|
31
|
+
fingerprints for an outbound-only smoke to `#sellable-acme` via:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
hermes --profile acme send --to slack:C0BFERDV3N0 --subject '[Hermes acme smoke]' ...
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That succeeded and is useful as an internal smoke test. It does not change the
|
|
38
|
+
inbound listener rule: only one profile should own an active Socket Mode
|
|
39
|
+
connection for a given `SLACK_APP_TOKEN`.
|
|
40
|
+
|
|
41
|
+
## Which Slack Values Are Enough
|
|
42
|
+
|
|
43
|
+
For outbound sends and ordinary Slack Web API calls, the important value is the
|
|
44
|
+
profile-local `SLACK_BOT_TOKEN`.
|
|
45
|
+
|
|
46
|
+
For native Hermes live listening through Slack Socket Mode, the profile also
|
|
47
|
+
needs `SLACK_APP_TOKEN`, an app-level `xapp-...` token with `connections:write`.
|
|
48
|
+
`SLACK_APP_ID`, `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`,
|
|
49
|
+
`SLACK_SIGNING_SECRET`, `SLACK_VERIFICATION_TOKEN`, `SLACK_TEAM_ID`, and
|
|
50
|
+
`SLACK_OPERATOR_EMAIL` do not replace `SLACK_APP_TOKEN`.
|
|
51
|
+
|
|
52
|
+
## Required Slack App Shape
|
|
53
|
+
|
|
54
|
+
Outbound Slack sends can work with fewer permissions. Native Hermes inbound
|
|
55
|
+
listening needs the Slack app itself to be configured for Socket Mode and event
|
|
56
|
+
delivery:
|
|
57
|
+
|
|
58
|
+
- Enable Socket Mode.
|
|
59
|
+
- Create an app-level token with `connections:write`.
|
|
60
|
+
- Install the bot with `app_mentions:read`, `channels:history`,
|
|
61
|
+
`channels:read`, `chat:write`, `groups:history`, `groups:read`,
|
|
62
|
+
`im:history`, `im:read`, `mpim:history`, `mpim:read`, and `users:read`.
|
|
63
|
+
- Subscribe bot events for `app_mention`, `message.channels`,
|
|
64
|
+
`message.groups`, `message.im`, and `message.mpim`.
|
|
65
|
+
- Reinstall the app after changing scopes or event subscriptions.
|
|
66
|
+
|
|
67
|
+
If the Slack app has `incoming-webhook`, Slack will ask for a webhook channel
|
|
68
|
+
during reinstall. Pick the profile home channel. That webhook selection does
|
|
69
|
+
not replace `SLACK_ALLOWED_CHANNELS`; keep the profile-local allowlist explicit.
|
|
70
|
+
|
|
71
|
+
## Bootstrap Command
|
|
72
|
+
|
|
73
|
+
Use the Sellable installer profile bootstrap:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
sellable hermes profile bootstrap \
|
|
77
|
+
--profile acme \
|
|
78
|
+
--profiles-root /srv/hermes/profiles \
|
|
79
|
+
--workspace-id ws_acme \
|
|
80
|
+
--workspace-name Acme \
|
|
81
|
+
--token-file /run/secrets/sellable-acme-token \
|
|
82
|
+
--slack-bot-token "$SLACK_BOT_TOKEN" \
|
|
83
|
+
--slack-app-token "$SLACK_APP_TOKEN" \
|
|
84
|
+
--slack-home-channel C0ACME12345 \
|
|
85
|
+
--slack-home-channel-name sellable-acme \
|
|
86
|
+
--slack-allowed-users U0OPERATOR1 \
|
|
87
|
+
--slack-require-mention true \
|
|
88
|
+
--json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If `--slack-home-channel` is supplied and `--slack-allowed-channels` is omitted,
|
|
92
|
+
the installer writes `SLACK_ALLOWED_CHANNELS=<home-channel-id>` automatically.
|
|
93
|
+
That makes the profile fail closed to the customer channel by default.
|
|
94
|
+
|
|
95
|
+
Use `--slack-allowed-channels C...,G...` only when a profile is intentionally
|
|
96
|
+
allowed to listen in more than one channel. Pass Slack IDs, not `#channel-name`
|
|
97
|
+
strings or wildcards.
|
|
98
|
+
|
|
99
|
+
## Resulting Profile Env
|
|
100
|
+
|
|
101
|
+
The profile-local `.env` should contain only scoped Slack keys for that profile:
|
|
102
|
+
|
|
103
|
+
```env
|
|
104
|
+
SLACK_BOT_TOKEN=...
|
|
105
|
+
SLACK_APP_TOKEN=...
|
|
106
|
+
SLACK_HOME_CHANNEL=C0ACME12345
|
|
107
|
+
SLACK_HOME_CHANNEL_NAME=sellable-acme
|
|
108
|
+
SLACK_ALLOWED_CHANNELS=C0ACME12345
|
|
109
|
+
SLACK_ALLOWED_USERS=U0OPERATOR1
|
|
110
|
+
SLACK_REQUIRE_MENTION=true
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Keep the file mode at `0600`. Do not copy raw tokens into planning docs, chat,
|
|
114
|
+
or committed artifacts. Record token fingerprints only.
|
|
115
|
+
|
|
116
|
+
## Verification
|
|
117
|
+
|
|
118
|
+
Run the installer UAT before publishing installer changes:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm run test:unit -- tests/install-package/agent-preferences.test.ts
|
|
122
|
+
scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode local-temp
|
|
123
|
+
scripts/run-phase92-hermes-profile-bootstrap-uat.sh --mode linux-shaped
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Expected proof:
|
|
127
|
+
|
|
128
|
+
- profile-local Sellable config paths remain authoritative
|
|
129
|
+
- generated profile `.env` has `SLACK_HOME_CHANNEL`
|
|
130
|
+
- generated profile `.env` has `SLACK_ALLOWED_CHANNELS`
|
|
131
|
+
- `acme` allowlist points at the `sellable-acme` fixture channel id
|
|
132
|
+
- redaction scan passes
|
|
133
|
+
|
|
134
|
+
## Current Phase 03 Status
|
|
135
|
+
|
|
136
|
+
Phase 03 has proved the profile-scoped installer path, outbound Hermes send
|
|
137
|
+
path, and live Slack listener for the `sellable-admin` profile. The
|
|
138
|
+
customer-specific app/token creation path remains the next hardening step before
|
|
139
|
+
multiple active customer listeners are safe.
|
package/README.md
CHANGED
|
@@ -119,29 +119,11 @@ It creates:
|
|
|
119
119
|
/srv/hermes/profiles/acme/sellable/config.json
|
|
120
120
|
/srv/hermes/profiles/acme/sellable/configs/
|
|
121
121
|
/srv/hermes/profiles/acme/skills/sellable/
|
|
122
|
-
/srv/hermes/bin/entrypoint-acme.sh
|
|
123
122
|
/srv/hermes/profiles/acme/.env # only when Slack token inputs are supplied
|
|
124
123
|
```
|
|
125
124
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`/srv/hermes/profiles/acme/skills/sellable` in the gateway root
|
|
129
|
-
`/srv/hermes/config.yaml` as `skills.external_dirs`. Keep this behavior:
|
|
130
|
-
Hermes Desktop command autocomplete scans the gateway/root skill config, not
|
|
131
|
-
only the selected profile's local skill tree.
|
|
132
|
-
|
|
133
|
-
Bootstrap also writes `/srv/hermes/bin/entrypoint-acme.sh`. Point the Hermes
|
|
134
|
-
Docker service at that script when this profile should be the Desktop/dashboard
|
|
135
|
-
default:
|
|
136
|
-
|
|
137
|
-
```yaml
|
|
138
|
-
entrypoint: ["/opt/data/bin/entrypoint-acme.sh"]
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The wrapper starts `hermes gateway run` when needed, then intentionally runs
|
|
142
|
-
`hermes -p acme dashboard --isolated`. Without `--isolated`, Hermes OS chat can
|
|
143
|
-
see `/sellable-*` commands while Hermes Desktop autocomplete misses them because
|
|
144
|
-
the dashboard backend is not scoped to the selected profile.
|
|
125
|
+
See [`HERMES-SLACK-PROFILE-SCOPING.md`](./HERMES-SLACK-PROFILE-SCOPING.md) for
|
|
126
|
+
the customer Slack isolation contract and Socket Mode token requirements.
|
|
145
127
|
|
|
146
128
|
If no token is supplied, bootstrap still creates a pending
|
|
147
129
|
`sellable/config.json` so the profile path is concrete from the first MCP
|
|
@@ -163,6 +145,36 @@ sellable hermes profile bootstrap \
|
|
|
163
145
|
--slack-app-token "$SLACK_APP_TOKEN"
|
|
164
146
|
```
|
|
165
147
|
|
|
148
|
+
The Slack app must also be configured for inbound Socket Mode delivery. Outbound
|
|
149
|
+
Slack Web API sends can work before this is complete, but Hermes will not hear
|
|
150
|
+
mentions until the app has Socket Mode enabled, an app-level token with
|
|
151
|
+
`connections:write`, bot scopes/events for `app_mention` plus channel/group/IM
|
|
152
|
+
message events, and a fresh workspace reinstall after scope or event changes.
|
|
153
|
+
|
|
154
|
+
Scope the profile to its customer channel at install time:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
sellable hermes profile bootstrap \
|
|
158
|
+
--profile acme \
|
|
159
|
+
--profiles-root /srv/hermes/profiles \
|
|
160
|
+
--workspace-id ws_acme \
|
|
161
|
+
--slack-bot-token "$SLACK_BOT_TOKEN" \
|
|
162
|
+
--slack-app-token "$SLACK_APP_TOKEN" \
|
|
163
|
+
--slack-home-channel C0ACME12345 \
|
|
164
|
+
--slack-home-channel-name sellable-acme \
|
|
165
|
+
--slack-allowed-users U0OPERATOR1 \
|
|
166
|
+
--slack-require-mention true
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
When `--slack-home-channel` is supplied without
|
|
170
|
+
`--slack-allowed-channels`, bootstrap writes
|
|
171
|
+
`SLACK_ALLOWED_CHANNELS=<home-channel-id>` too. That fail-closes a customer
|
|
172
|
+
profile to the intended channel by default. Use
|
|
173
|
+
`--slack-allowed-channels C...,G...` only when a profile is deliberately allowed
|
|
174
|
+
to listen in multiple channels. Pass channel IDs, not `#channel-name`, and use
|
|
175
|
+
separate Slack bot/app tokens per customer profile when you need hard isolation
|
|
176
|
+
between concurrently listening Hermes profiles.
|
|
177
|
+
|
|
166
178
|
Customer profiles should use customer-scoped Sellable credentials. A shared
|
|
167
179
|
Sellable admin token can work for internal admin automation, but it is weaker
|
|
168
180
|
isolation and should not be the default for customer profiles. After changing
|
package/bin/sellable-install.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import {
|
|
16
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
17
17
|
import { stdout as output } from "node:process";
|
|
18
18
|
import { createInterface } from "node:readline/promises";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -217,9 +217,11 @@ Hermes profile bootstrap:
|
|
|
217
217
|
|
|
218
218
|
Bootstrap writes <profileRoot>/config.yaml, <profileRoot>/sellable/config.json,
|
|
219
219
|
<profileRoot>/sellable/configs, Sellable Hermes skills, and optional
|
|
220
|
-
<profileRoot>/.env Slack token keys.
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
<profileRoot>/.env Slack token and channel-scope keys. For customer profiles,
|
|
221
|
+
pass --slack-home-channel; bootstrap defaults SLACK_ALLOWED_CHANNELS to that
|
|
222
|
+
exact channel id unless --slack-allowed-channels is supplied. Use
|
|
223
|
+
customer-scoped Sellable and Slack credentials for customer profiles; shared
|
|
224
|
+
admin tokens are weaker isolation and should remain internal-only.
|
|
223
225
|
`;
|
|
224
226
|
}
|
|
225
227
|
|
|
@@ -1180,10 +1182,9 @@ const REFRESH_SENDER_ENGAGEMENT_ALLOWED_TOOLS = [
|
|
|
1180
1182
|
"mcp__sellable__get_campaign_table_schema",
|
|
1181
1183
|
"mcp__sellable__list_tables",
|
|
1182
1184
|
"mcp__sellable__get_rows_minimal",
|
|
1185
|
+
"mcp__sellable__refresh_sender_engagement",
|
|
1183
1186
|
"mcp__sellable__fetch_linkedin_posts",
|
|
1184
|
-
"mcp__sellable__select_promising_posts",
|
|
1185
1187
|
"mcp__sellable__fetch_post_engagers",
|
|
1186
|
-
"mcp__sellable__add_on_demand_leads",
|
|
1187
1188
|
];
|
|
1188
1189
|
|
|
1189
1190
|
function allowedToolsYaml(tools) {
|
|
@@ -2145,10 +2146,10 @@ ${allowedToolsYaml(REFRESH_SENDER_ENGAGEMENT_ALLOWED_TOOLS)}
|
|
|
2145
2146
|
|
|
2146
2147
|
Use this as the customer-facing entrypoint for the Sellable
|
|
2147
2148
|
\`refresh-sender-engagement\` workflow. It finds active sender-owned Post
|
|
2148
|
-
Engagers
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
campaigns.
|
|
2149
|
+
Engagers campaigns, runs the typed \`refresh_sender_engagement\` command in
|
|
2150
|
+
\`mode:"dry_run"\`, and gates any \`mode:"apply"\` call with the returned
|
|
2151
|
+
\`dryRunFingerprint\`. It never creates messages, approves rows, schedules
|
|
2152
|
+
sends, or launches campaigns.
|
|
2152
2153
|
|
|
2153
2154
|
## Bootstrap
|
|
2154
2155
|
|
|
@@ -2168,9 +2169,15 @@ Desktop, then start a new thread.
|
|
|
2168
2169
|
\`mcp__sellable__get_subskill_prompt({ subskillName: "refresh-sender-engagement" })\`.
|
|
2169
2170
|
If the response has \`hasMore=true\`, continue with \`nextOffset\` until
|
|
2170
2171
|
\`hasMore=false\`.
|
|
2171
|
-
3. Follow the canonical prompt exactly.
|
|
2172
|
-
|
|
2173
|
-
|
|
2172
|
+
3. Follow the canonical prompt exactly. Prefer
|
|
2173
|
+
\`mcp__sellable__refresh_sender_engagement\` for dry-run and apply. Do not use
|
|
2174
|
+
primitive lead import tools as an alternate write path.
|
|
2175
|
+
4. For manual runs, show the dry-run target workspace, sender, campaign/table,
|
|
2176
|
+
expected impact, and \`dryRunFingerprint\`; wait for explicit approval before
|
|
2177
|
+
apply. For scheduled automations, apply only with the fingerprint from the
|
|
2178
|
+
immediately preceding dry run.
|
|
2179
|
+
5. Do not touch shared lanes, campaign status, approval, message preparation, or
|
|
2180
|
+
send-state tools.
|
|
2174
2181
|
|
|
2175
2182
|
## MCP Prompt Fallback
|
|
2176
2183
|
|
|
@@ -3663,87 +3670,6 @@ function hermesSkillsRoot(opts = {}) {
|
|
|
3663
3670
|
return join(hermesHome(opts), "skills", "sellable");
|
|
3664
3671
|
}
|
|
3665
3672
|
|
|
3666
|
-
function hermesGatewayRoot(opts = {}) {
|
|
3667
|
-
const profileRoot = hermesHome(opts);
|
|
3668
|
-
const profilesRoot = dirname(profileRoot);
|
|
3669
|
-
if (basename(profilesRoot) !== "profiles") return null;
|
|
3670
|
-
return dirname(profilesRoot);
|
|
3671
|
-
}
|
|
3672
|
-
|
|
3673
|
-
function hermesGatewayConfigPath(opts = {}) {
|
|
3674
|
-
const root = hermesGatewayRoot(opts);
|
|
3675
|
-
if (!root) return null;
|
|
3676
|
-
return join(root, "config.yaml");
|
|
3677
|
-
}
|
|
3678
|
-
|
|
3679
|
-
function hermesDashboardEntrypointPath(opts = {}, profile = "") {
|
|
3680
|
-
const root = hermesGatewayRoot(opts);
|
|
3681
|
-
if (!root) return null;
|
|
3682
|
-
const safeProfile = String(profile || basenameSafe(hermesHome(opts))).replace(
|
|
3683
|
-
/[^A-Za-z0-9_.-]/g,
|
|
3684
|
-
"_"
|
|
3685
|
-
);
|
|
3686
|
-
return join(root, "bin", `entrypoint-${safeProfile}.sh`);
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
function hermesDashboardCommand(profile) {
|
|
3690
|
-
return `hermes -p ${profile} dashboard --isolated --host 0.0.0.0 --port 4860 --no-open --skip-build`;
|
|
3691
|
-
}
|
|
3692
|
-
|
|
3693
|
-
function hermesDashboardEntrypointContent(opts, profile) {
|
|
3694
|
-
const quotedProfile = shellQuote(profile);
|
|
3695
|
-
const quotedProfileRoot = shellQuote(hermesHome(opts));
|
|
3696
|
-
const gatewayRoot = hermesGatewayRoot(opts);
|
|
3697
|
-
const quotedGatewayRoot = shellQuote(gatewayRoot || dirname(hermesHome(opts)));
|
|
3698
|
-
const quotedDashboardLog = shellQuote(join(hermesHome(opts), "logs", "dashboard.log"));
|
|
3699
|
-
return `#!/usr/bin/env bash
|
|
3700
|
-
set -euo pipefail
|
|
3701
|
-
|
|
3702
|
-
# This file is generated by sellable hermes profile bootstrap.
|
|
3703
|
-
# It preserves the Hostinger image startup shape, but launches the dashboard in
|
|
3704
|
-
# an isolated Sellable profile. Without --isolated, Hermes OS chat can see
|
|
3705
|
-
# profile skills while Desktop slash autocomplete stays on the default profile.
|
|
3706
|
-
profile=${quotedProfile}
|
|
3707
|
-
profile_root=${quotedProfileRoot}
|
|
3708
|
-
gateway_root=${quotedGatewayRoot}
|
|
3709
|
-
dashboard_log=${quotedDashboardLog}
|
|
3710
|
-
dashboard_port="\${HERMES_DASHBOARD_PORT:-4860}"
|
|
3711
|
-
run_as=()
|
|
3712
|
-
if command -v gosu >/dev/null 2>&1; then
|
|
3713
|
-
run_as=(gosu hermes)
|
|
3714
|
-
fi
|
|
3715
|
-
|
|
3716
|
-
chown -R hermes:hermes "$gateway_root" 2>/dev/null || true
|
|
3717
|
-
mkdir -p "$gateway_root/logs" "$profile_root/logs"
|
|
3718
|
-
chown -R hermes:hermes "$gateway_root/logs" "$profile_root/logs" 2>/dev/null || true
|
|
3719
|
-
|
|
3720
|
-
if [[ -f "$gateway_root/config.yaml" ]]; then
|
|
3721
|
-
if ! pgrep -f "hermes gateway run" >/dev/null 2>&1; then
|
|
3722
|
-
"\${run_as[@]}" nohup hermes gateway run >>"$gateway_root/logs/gateway.log" 2>&1 </dev/null &
|
|
3723
|
-
fi
|
|
3724
|
-
fi
|
|
3725
|
-
|
|
3726
|
-
for v in ADMIN_USERNAME ADMIN_PASSWORD; do
|
|
3727
|
-
if [[ -z "\${!v:-}" ]]; then
|
|
3728
|
-
echo "missing required environment variable $v" >&2
|
|
3729
|
-
exit 1
|
|
3730
|
-
fi
|
|
3731
|
-
done
|
|
3732
|
-
|
|
3733
|
-
export HERMES_DASHBOARD_BASIC_AUTH_USERNAME="$ADMIN_USERNAME"
|
|
3734
|
-
export HERMES_DASHBOARD_BASIC_AUTH_PASSWORD="$ADMIN_PASSWORD"
|
|
3735
|
-
|
|
3736
|
-
exec "\${run_as[@]}" hermes -p "$profile" dashboard --isolated --host 0.0.0.0 --port "$dashboard_port" --no-open --skip-build >>"$dashboard_log" 2>&1
|
|
3737
|
-
`;
|
|
3738
|
-
}
|
|
3739
|
-
|
|
3740
|
-
function writeHermesDashboardEntrypoint(opts, profile) {
|
|
3741
|
-
const path = hermesDashboardEntrypointPath(opts, profile);
|
|
3742
|
-
if (!path) return null;
|
|
3743
|
-
writeFile(path, hermesDashboardEntrypointContent(opts, profile), opts, 0o755);
|
|
3744
|
-
return path;
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
3673
|
function hermesLikelyInstalled() {
|
|
3748
3674
|
return (
|
|
3749
3675
|
Boolean(process.env.HERMES_HOME?.trim()) ||
|
|
@@ -3823,38 +3749,6 @@ function writeHermesMcpServer(opts) {
|
|
|
3823
3749
|
return configPath;
|
|
3824
3750
|
}
|
|
3825
3751
|
|
|
3826
|
-
function writeHermesGatewaySkillDirectory(opts) {
|
|
3827
|
-
const configPath = hermesGatewayConfigPath(opts);
|
|
3828
|
-
if (!configPath || configPath === hermesConfigPath(opts)) return null;
|
|
3829
|
-
|
|
3830
|
-
const skillDir = hermesSkillsRoot(opts);
|
|
3831
|
-
const raw = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
3832
|
-
const config = parseHermesConfig(raw, configPath);
|
|
3833
|
-
// Hermes Desktop slash autocomplete scans the gateway/root HERMES_HOME. A
|
|
3834
|
-
// profile can execute its own skills without this, but Desktop will not show
|
|
3835
|
-
// /sellable-* commands until the root scanner sees the profile skill dir.
|
|
3836
|
-
const skills =
|
|
3837
|
-
config.skills && typeof config.skills === "object" && !Array.isArray(config.skills)
|
|
3838
|
-
? config.skills
|
|
3839
|
-
: {};
|
|
3840
|
-
const current = Array.isArray(skills.external_dirs)
|
|
3841
|
-
? skills.external_dirs
|
|
3842
|
-
: skills.external_dirs
|
|
3843
|
-
? [skills.external_dirs]
|
|
3844
|
-
: [];
|
|
3845
|
-
const externalDirs = current.includes(skillDir) ? current : [...current, skillDir];
|
|
3846
|
-
const nextConfig = {
|
|
3847
|
-
...config,
|
|
3848
|
-
skills: {
|
|
3849
|
-
...skills,
|
|
3850
|
-
external_dirs: externalDirs,
|
|
3851
|
-
},
|
|
3852
|
-
};
|
|
3853
|
-
|
|
3854
|
-
writeHermesConfig(configPath, nextConfig, opts);
|
|
3855
|
-
return { configPath, skillDir };
|
|
3856
|
-
}
|
|
3857
|
-
|
|
3858
3752
|
function installHermesSkills(opts) {
|
|
3859
3753
|
const root = hermesSkillsRoot(opts);
|
|
3860
3754
|
for (const skill of hermesSkillDefinitions()) {
|
|
@@ -3875,15 +3769,8 @@ function installHermes(opts) {
|
|
|
3875
3769
|
try {
|
|
3876
3770
|
skillsRoot = installHermesSkills(opts);
|
|
3877
3771
|
const configPath = writeHermesMcpServer(opts);
|
|
3878
|
-
const gateway = writeHermesGatewaySkillDirectory(opts);
|
|
3879
3772
|
logVerbose(`${C.grey}+ Hermes config updated: ${configPath}${C.reset}`);
|
|
3880
|
-
return {
|
|
3881
|
-
installed: true,
|
|
3882
|
-
configPath,
|
|
3883
|
-
skillsRoot,
|
|
3884
|
-
gatewayConfigPath: gateway?.configPath || null,
|
|
3885
|
-
gatewayExternalSkillsDir: gateway?.skillDir || null,
|
|
3886
|
-
};
|
|
3773
|
+
return { installed: true, configPath, skillsRoot };
|
|
3887
3774
|
} catch (err) {
|
|
3888
3775
|
if (skillsRoot && !opts.dryRun) {
|
|
3889
3776
|
rmSync(skillsRoot, { recursive: true, force: true });
|
|
@@ -3904,6 +3791,12 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3904
3791
|
apiUrl: process.env.SELLABLE_API_URL || DEFAULT_API_URL,
|
|
3905
3792
|
slackBotToken: process.env.SLACK_BOT_TOKEN || "",
|
|
3906
3793
|
slackAppToken: process.env.SLACK_APP_TOKEN || "",
|
|
3794
|
+
slackHomeChannel: process.env.SLACK_HOME_CHANNEL || "",
|
|
3795
|
+
slackHomeChannelName: process.env.SLACK_HOME_CHANNEL_NAME || "",
|
|
3796
|
+
slackAllowedChannels: process.env.SLACK_ALLOWED_CHANNELS || "",
|
|
3797
|
+
slackFreeResponseChannels: process.env.SLACK_FREE_RESPONSE_CHANNELS || "",
|
|
3798
|
+
slackAllowedUsers: process.env.SLACK_ALLOWED_USERS || "",
|
|
3799
|
+
slackRequireMention: process.env.SLACK_REQUIRE_MENTION || "",
|
|
3907
3800
|
server: process.env.SELLABLE_INSTALL_SERVER || "package",
|
|
3908
3801
|
mcpPackage: DEFAULT_SERVER_PACKAGE,
|
|
3909
3802
|
localCommand: process.env.SELLABLE_MCP_LOCAL_COMMAND || "",
|
|
@@ -3936,6 +3829,12 @@ function parseHermesProfileBootstrapArgs(argv) {
|
|
|
3936
3829
|
else if (arg === "--api-url") opts.apiUrl = next();
|
|
3937
3830
|
else if (arg === "--slack-bot-token") opts.slackBotToken = next();
|
|
3938
3831
|
else if (arg === "--slack-app-token") opts.slackAppToken = next();
|
|
3832
|
+
else if (arg === "--slack-home-channel") opts.slackHomeChannel = next();
|
|
3833
|
+
else if (arg === "--slack-home-channel-name") opts.slackHomeChannelName = next();
|
|
3834
|
+
else if (arg === "--slack-allowed-channels") opts.slackAllowedChannels = next();
|
|
3835
|
+
else if (arg === "--slack-free-response-channels") opts.slackFreeResponseChannels = next();
|
|
3836
|
+
else if (arg === "--slack-allowed-users") opts.slackAllowedUsers = next();
|
|
3837
|
+
else if (arg === "--slack-require-mention") opts.slackRequireMention = next();
|
|
3939
3838
|
else if (arg === "--server") opts.server = next();
|
|
3940
3839
|
else if (arg === "--mcp-package") {
|
|
3941
3840
|
opts.mcpPackage = normalizeWindowsPackageSpec(next());
|
|
@@ -4037,6 +3936,111 @@ function renderEnvFile({ entries, passthrough }) {
|
|
|
4037
3936
|
return `${lines.join("\n")}${lines.length ? "\n" : ""}`;
|
|
4038
3937
|
}
|
|
4039
3938
|
|
|
3939
|
+
function normalizeSlackChannelIds(value, key) {
|
|
3940
|
+
const raw = String(value || "").trim();
|
|
3941
|
+
if (!raw) return "";
|
|
3942
|
+
const ids = raw
|
|
3943
|
+
.split(",")
|
|
3944
|
+
.map((item) => item.trim())
|
|
3945
|
+
.filter(Boolean);
|
|
3946
|
+
if (ids.length === 0) return "";
|
|
3947
|
+
|
|
3948
|
+
for (const id of ids) {
|
|
3949
|
+
if (
|
|
3950
|
+
id === "*" ||
|
|
3951
|
+
/^all$/i.test(id) ||
|
|
3952
|
+
id.startsWith("#") ||
|
|
3953
|
+
/\s/.test(id) ||
|
|
3954
|
+
!/^[A-Z][A-Z0-9]{8,}$/.test(id)
|
|
3955
|
+
) {
|
|
3956
|
+
throw new Error(
|
|
3957
|
+
`${key} must be comma-separated Slack channel IDs, not names, wildcards, or free text. Bad value: ${id}`
|
|
3958
|
+
);
|
|
3959
|
+
}
|
|
3960
|
+
}
|
|
3961
|
+
return [...new Set(ids)].join(",");
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
function normalizeSlackSingleChannelId(value, key) {
|
|
3965
|
+
const normalized = normalizeSlackChannelIds(value, key);
|
|
3966
|
+
if (normalized.includes(",")) {
|
|
3967
|
+
throw new Error(`${key} must be a single Slack channel ID`);
|
|
3968
|
+
}
|
|
3969
|
+
return normalized;
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
function normalizeSlackUserIds(value, key) {
|
|
3973
|
+
const raw = String(value || "").trim();
|
|
3974
|
+
if (!raw) return "";
|
|
3975
|
+
const ids = raw
|
|
3976
|
+
.split(",")
|
|
3977
|
+
.map((item) => item.trim())
|
|
3978
|
+
.filter(Boolean);
|
|
3979
|
+
if (ids.length === 0) return "";
|
|
3980
|
+
|
|
3981
|
+
for (const id of ids) {
|
|
3982
|
+
if (!/^[UW][A-Z0-9]{8,}$/.test(id)) {
|
|
3983
|
+
throw new Error(`${key} must be comma-separated Slack user IDs. Bad value: ${id}`);
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
return [...new Set(ids)].join(",");
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
function normalizeSlackChannelName(value, key) {
|
|
3990
|
+
const raw = String(value || "").trim();
|
|
3991
|
+
if (!raw) return "";
|
|
3992
|
+
if (raw.startsWith("#") || /[\s,]/.test(raw)) {
|
|
3993
|
+
throw new Error(`${key} must be a Slack channel name without #, spaces, or commas`);
|
|
3994
|
+
}
|
|
3995
|
+
return raw;
|
|
3996
|
+
}
|
|
3997
|
+
|
|
3998
|
+
function normalizeSlackBoolean(value, key) {
|
|
3999
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
4000
|
+
if (!raw) return "";
|
|
4001
|
+
if (raw !== "true" && raw !== "false") {
|
|
4002
|
+
throw new Error(`${key} must be true or false`);
|
|
4003
|
+
}
|
|
4004
|
+
return raw;
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
function buildSlackProfileEnv(parsed) {
|
|
4008
|
+
const homeChannel = normalizeSlackSingleChannelId(
|
|
4009
|
+
parsed.slackHomeChannel,
|
|
4010
|
+
"SLACK_HOME_CHANNEL"
|
|
4011
|
+
);
|
|
4012
|
+
const explicitAllowedChannels = normalizeSlackChannelIds(
|
|
4013
|
+
parsed.slackAllowedChannels,
|
|
4014
|
+
"SLACK_ALLOWED_CHANNELS"
|
|
4015
|
+
);
|
|
4016
|
+
const allowedChannels = explicitAllowedChannels || homeChannel;
|
|
4017
|
+
|
|
4018
|
+
return {
|
|
4019
|
+
SLACK_BOT_TOKEN: parsed.slackBotToken.trim(),
|
|
4020
|
+
SLACK_APP_TOKEN: parsed.slackAppToken.trim(),
|
|
4021
|
+
SLACK_HOME_CHANNEL: homeChannel,
|
|
4022
|
+
SLACK_HOME_CHANNEL_NAME: normalizeSlackChannelName(
|
|
4023
|
+
parsed.slackHomeChannelName,
|
|
4024
|
+
"SLACK_HOME_CHANNEL_NAME"
|
|
4025
|
+
),
|
|
4026
|
+
// Customer Hermes profiles must fail closed to their channel. If the caller
|
|
4027
|
+
// supplies only a home channel, we treat that as the allowlist too.
|
|
4028
|
+
SLACK_ALLOWED_CHANNELS: allowedChannels,
|
|
4029
|
+
SLACK_FREE_RESPONSE_CHANNELS: normalizeSlackChannelIds(
|
|
4030
|
+
parsed.slackFreeResponseChannels,
|
|
4031
|
+
"SLACK_FREE_RESPONSE_CHANNELS"
|
|
4032
|
+
),
|
|
4033
|
+
SLACK_ALLOWED_USERS: normalizeSlackUserIds(
|
|
4034
|
+
parsed.slackAllowedUsers,
|
|
4035
|
+
"SLACK_ALLOWED_USERS"
|
|
4036
|
+
),
|
|
4037
|
+
SLACK_REQUIRE_MENTION: normalizeSlackBoolean(
|
|
4038
|
+
parsed.slackRequireMention,
|
|
4039
|
+
"SLACK_REQUIRE_MENTION"
|
|
4040
|
+
),
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4040
4044
|
function findDuplicateSlackTokenProfiles(profileRoot, slackTokens) {
|
|
4041
4045
|
const duplicates = [];
|
|
4042
4046
|
const profilesRoot = dirname(profileRoot);
|
|
@@ -4089,10 +4093,14 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4089
4093
|
const sellableConfigsDir = join(profileRoot, "sellable", "configs");
|
|
4090
4094
|
const envPath = join(profileRoot, ".env");
|
|
4091
4095
|
const { token, source: tokenSource } = readBootstrapToken(parsed);
|
|
4096
|
+
const slackEnv = buildSlackProfileEnv(parsed);
|
|
4092
4097
|
const slackTokens = {
|
|
4093
|
-
SLACK_BOT_TOKEN:
|
|
4094
|
-
SLACK_APP_TOKEN:
|
|
4098
|
+
SLACK_BOT_TOKEN: slackEnv.SLACK_BOT_TOKEN,
|
|
4099
|
+
SLACK_APP_TOKEN: slackEnv.SLACK_APP_TOKEN,
|
|
4095
4100
|
};
|
|
4101
|
+
const slackEnvKeys = Object.entries(slackEnv)
|
|
4102
|
+
.filter(([, value]) => value)
|
|
4103
|
+
.map(([key]) => key);
|
|
4096
4104
|
const opts = {
|
|
4097
4105
|
...parsed,
|
|
4098
4106
|
host: "hermes",
|
|
@@ -4121,8 +4129,6 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4121
4129
|
sellableConfigPath,
|
|
4122
4130
|
sellableConfigsDir,
|
|
4123
4131
|
skillsRoot: hermesSkillsRoot(opts),
|
|
4124
|
-
gatewayConfigPath: hermesGatewayConfigPath(opts),
|
|
4125
|
-
hermesDashboardEntrypointPath: hermesDashboardEntrypointPath(opts, profile),
|
|
4126
4132
|
},
|
|
4127
4133
|
auth: {
|
|
4128
4134
|
apiUrl: opts.apiUrl,
|
|
@@ -4136,17 +4142,29 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4136
4142
|
keys: Object.entries(slackTokens)
|
|
4137
4143
|
.filter(([, value]) => value)
|
|
4138
4144
|
.map(([key]) => key),
|
|
4145
|
+
envKeys: slackEnvKeys,
|
|
4139
4146
|
fingerprints: Object.fromEntries(
|
|
4140
4147
|
Object.entries(slackTokens)
|
|
4141
4148
|
.filter(([, value]) => value)
|
|
4142
4149
|
.map(([key, value]) => [key, tokenFingerprint(value)])
|
|
4143
4150
|
),
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4151
|
+
scope: {
|
|
4152
|
+
homeChannel: slackEnv.SLACK_HOME_CHANNEL || null,
|
|
4153
|
+
homeChannelName: slackEnv.SLACK_HOME_CHANNEL_NAME || null,
|
|
4154
|
+
allowedChannels: slackEnv.SLACK_ALLOWED_CHANNELS
|
|
4155
|
+
? slackEnv.SLACK_ALLOWED_CHANNELS.split(",")
|
|
4156
|
+
: [],
|
|
4157
|
+
freeResponseChannels: slackEnv.SLACK_FREE_RESPONSE_CHANNELS
|
|
4158
|
+
? slackEnv.SLACK_FREE_RESPONSE_CHANNELS.split(",")
|
|
4159
|
+
: [],
|
|
4160
|
+
allowedUsersCount: slackEnv.SLACK_ALLOWED_USERS
|
|
4161
|
+
? slackEnv.SLACK_ALLOWED_USERS.split(",").length
|
|
4162
|
+
: 0,
|
|
4163
|
+
requireMention:
|
|
4164
|
+
slackEnv.SLACK_REQUIRE_MENTION === ""
|
|
4165
|
+
? null
|
|
4166
|
+
: slackEnv.SLACK_REQUIRE_MENTION === "true",
|
|
4167
|
+
},
|
|
4150
4168
|
},
|
|
4151
4169
|
created: [],
|
|
4152
4170
|
updated: [],
|
|
@@ -4191,7 +4209,7 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4191
4209
|
if (existsSync(envPath)) {
|
|
4192
4210
|
envState = parseEnvLines(readFileSync(envPath, "utf8"));
|
|
4193
4211
|
}
|
|
4194
|
-
for (const [key, value] of Object.entries(
|
|
4212
|
+
for (const [key, value] of Object.entries(slackEnv)) {
|
|
4195
4213
|
if (!value) continue;
|
|
4196
4214
|
const existing = envState.entries.get(key);
|
|
4197
4215
|
if (existing && existing !== value && !opts.overwriteEnv) {
|
|
@@ -4213,12 +4231,6 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4213
4231
|
}
|
|
4214
4232
|
|
|
4215
4233
|
const hermesConfigExisted = existsSync(hermesConfigPath(opts));
|
|
4216
|
-
const gatewayConfigPath = hermesGatewayConfigPath(opts);
|
|
4217
|
-
const gatewayConfigExisted = gatewayConfigPath ? existsSync(gatewayConfigPath) : false;
|
|
4218
|
-
const dashboardEntrypointPath = hermesDashboardEntrypointPath(opts, profile);
|
|
4219
|
-
const dashboardEntrypointExisted = dashboardEntrypointPath
|
|
4220
|
-
? existsSync(dashboardEntrypointPath)
|
|
4221
|
-
: false;
|
|
4222
4234
|
const skillsExisted = existsSync(hermesSkillsRoot(opts));
|
|
4223
4235
|
const authExisted = existsSync(sellableConfigPath);
|
|
4224
4236
|
const configsDirExisted = existsSync(sellableConfigsDir);
|
|
@@ -4257,9 +4269,8 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4257
4269
|
mkdirSync(sellableConfigsDir, { recursive: true, mode: 0o700 });
|
|
4258
4270
|
writeHermesProfileSellableConfig(sellableConfigPath, nextAuth, opts);
|
|
4259
4271
|
installHermes(opts);
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
for (const [key, value] of Object.entries(slackTokens)) {
|
|
4272
|
+
if (Object.values(slackEnv).some(Boolean)) {
|
|
4273
|
+
for (const [key, value] of Object.entries(slackEnv)) {
|
|
4263
4274
|
if (value) envState.entries.set(key, value);
|
|
4264
4275
|
}
|
|
4265
4276
|
writeFile(envPath, renderEnvFile(envState), opts, 0o600);
|
|
@@ -4273,25 +4284,21 @@ function runHermesProfileBootstrap(argv) {
|
|
|
4273
4284
|
(hermesConfigExisted ? summary.updated : summary.created).push(
|
|
4274
4285
|
hermesConfigPath(opts)
|
|
4275
4286
|
);
|
|
4276
|
-
if (gatewayConfigPath) {
|
|
4277
|
-
(gatewayConfigExisted ? summary.updated : summary.created).push(
|
|
4278
|
-
gatewayConfigPath
|
|
4279
|
-
);
|
|
4280
|
-
}
|
|
4281
|
-
if (dashboardEntrypointPath) {
|
|
4282
|
-
(dashboardEntrypointExisted ? summary.updated : summary.created).push(
|
|
4283
|
-
dashboardEntrypointPath
|
|
4284
|
-
);
|
|
4285
|
-
} else {
|
|
4286
|
-
summary.skipped.push(
|
|
4287
|
-
"Hermes dashboard entrypoint: profile root is not under a gateway profiles directory"
|
|
4288
|
-
);
|
|
4289
|
-
}
|
|
4290
4287
|
(skillsExisted ? summary.updated : summary.created).push(hermesSkillsRoot(opts));
|
|
4291
|
-
if (Object.values(
|
|
4288
|
+
if (Object.values(slackEnv).some(Boolean)) {
|
|
4292
4289
|
(envExisted ? summary.updated : summary.created).push(envPath);
|
|
4293
4290
|
} else {
|
|
4294
|
-
summary.skipped.push("Slack env: no Slack tokens supplied");
|
|
4291
|
+
summary.skipped.push("Slack env: no Slack tokens or channel scope supplied");
|
|
4292
|
+
}
|
|
4293
|
+
if (slackEnv.SLACK_HOME_CHANNEL && !parsed.slackAllowedChannels.trim()) {
|
|
4294
|
+
summary.warnings.push(
|
|
4295
|
+
"Slack scope defaulted SLACK_ALLOWED_CHANNELS to SLACK_HOME_CHANNEL for fail-closed profile listening."
|
|
4296
|
+
);
|
|
4297
|
+
}
|
|
4298
|
+
if (slackEnv.SLACK_BOT_TOKEN && !slackEnv.SLACK_APP_TOKEN) {
|
|
4299
|
+
summary.warnings.push(
|
|
4300
|
+
"Slack bot token is present without SLACK_APP_TOKEN; outbound Slack sends may work, but native Hermes Socket Mode listening still requires an xapp app-level token with connections:write."
|
|
4301
|
+
);
|
|
4295
4302
|
}
|
|
4296
4303
|
if (!token) {
|
|
4297
4304
|
summary.warnings.push(
|
package/lib/runtime-verify.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export const REQUIRED_SELLABLE_MCP_TOOLS = [
|
|
|
35
35
|
"refresh_paid_inmail_credits",
|
|
36
36
|
"run_scheduler_sweep",
|
|
37
37
|
"refill_sends",
|
|
38
|
+
"refresh_sender_engagement",
|
|
38
39
|
"setup_evergreen_campaigns",
|
|
39
40
|
"start_campaign_message_preparation",
|
|
40
41
|
"get_campaign_message_preparation_status",
|
|
@@ -75,6 +76,14 @@ export const CREATE_CAMPAIGN_SMOKE_CALLS = [
|
|
|
75
76
|
limit: 1200,
|
|
76
77
|
},
|
|
77
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: "get_subskill_prompt",
|
|
81
|
+
arguments: {
|
|
82
|
+
subskillName: "refresh-sender-engagement",
|
|
83
|
+
offset: 0,
|
|
84
|
+
limit: 1200,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
78
87
|
{
|
|
79
88
|
name: "refill_sends",
|
|
80
89
|
arguments: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sellable/install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.311",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One-command installer for Sellable MCP in Claude Code, Codex, and Hermes",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"lib",
|
|
16
16
|
"agents",
|
|
17
17
|
"skill-templates",
|
|
18
|
-
"README.md"
|
|
18
|
+
"README.md",
|
|
19
|
+
"HERMES-SLACK-PROFILE-SCOPING.md"
|
|
19
20
|
],
|
|
20
21
|
"dependencies": {
|
|
21
22
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
@@ -19,10 +19,9 @@ allowed-tools:
|
|
|
19
19
|
- mcp__sellable__get_campaign_table_schema
|
|
20
20
|
- mcp__sellable__list_tables
|
|
21
21
|
- mcp__sellable__get_rows_minimal
|
|
22
|
+
- mcp__sellable__refresh_sender_engagement
|
|
22
23
|
- mcp__sellable__fetch_linkedin_posts
|
|
23
|
-
- mcp__sellable__select_promising_posts
|
|
24
24
|
- mcp__sellable__fetch_post_engagers
|
|
25
|
-
- mcp__sellable__add_on_demand_leads
|
|
26
25
|
---
|
|
27
26
|
|
|
28
27
|
# Refresh Sender Engagement
|
|
@@ -34,7 +33,7 @@ You are a pipeline supply agent. People who engage with a sender's LinkedIn post
|
|
|
34
33
|
<inputs>
|
|
35
34
|
The invoking prompt names the senders ("refresh sender engagement for csreyes92 and thomas"). Resolve each via `list_senders` (match name/handle/LinkedIn URL). With no names given, inspect the active workspace and refresh every connected sender that has an active/paused sender-owned Post Engagers campaign backed by Signal Discovery.
|
|
36
35
|
|
|
37
|
-
Optional:
|
|
36
|
+
Optional: target sender names/ids, `tableId` when the user wants to force a specific campaign table, maximum posts per sender (default 5, hard cap 5 unless the user explicitly asks for more), and maximum engager pages per tracked post.
|
|
38
37
|
</inputs>
|
|
39
38
|
|
|
40
39
|
<entrypoint>
|
|
@@ -53,39 +52,36 @@ suggest `refill-sends`.
|
|
|
53
52
|
<objective>
|
|
54
53
|
For each target sender/campaign:
|
|
55
54
|
|
|
56
|
-
1. **
|
|
55
|
+
1. **Authenticate and resolve workspace**:
|
|
57
56
|
- Call `get_auth_status` and confirm the active workspace.
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
|
|
71
|
-
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
6. **
|
|
77
|
-
- Use `add_on_demand_leads({ tableId, leads, skipDuplicates: true })` with name, headline-derived title, profile URL, and source/post context where the tool accepts it.
|
|
78
|
-
- Dedupe is mandatory. Never disable dedupe on a scheduled run.
|
|
79
|
-
- Do not generate, approve, schedule, or send messages.
|
|
80
|
-
7. **Report**: campaigns inspected, target campaigns selected, posts scanned, engagers found, ICP-passing, net-new added per sender/campaign. If a sender posted nothing in the window, say "no recent posts — nothing to refresh" (that is a truthful no-op, not a failure).
|
|
57
|
+
- If there is no active workspace or auth is missing, stop with the MCP guidance. Do not use local env files or repo scripts as a substitute for MCP auth.
|
|
58
|
+
- Resolve each target sender with `list_senders`/`get_sender`. Use the sender id returned by the product, not a guessed handle.
|
|
59
|
+
2. **Use the typed product command first**:
|
|
60
|
+
- Call `refresh_sender_engagement` in `mode:"dry_run"` for each sender.
|
|
61
|
+
- Always pass `workspaceId`, `senderId`, and any user-specified `tableId`, `maxPosts`, or `maxEngagerPages`.
|
|
62
|
+
- Treat the dry-run response as the campaign/source boundary authority. It should identify the sender-owned Post Engagers slot, campaign, workflow table, source provider `campaign-tracked-post`, source table type `tracked_post_engager_source_list`, expected tracked posts, expected engager refresh/import work, and a `dryRunFingerprint`.
|
|
63
|
+
- If the typed command reports no eligible sender-owned Post Engagers campaign, no tracked posts, no recent posts, or a workspace/sender mismatch, report that exact blocker and stop for that sender. Do not silently create campaigns or fall back to shared lanes.
|
|
64
|
+
3. **Gate writes with dry-run proof**:
|
|
65
|
+
- Never call `refresh_sender_engagement` in `mode:"apply"` before a successful dry run from this same run.
|
|
66
|
+
- For manual runs, show the target workspace, sender, campaign/table, source lead list, expected tracked posts, expected row/import impact, and `dryRunFingerprint`, then wait for explicit user approval before apply.
|
|
67
|
+
- For scheduled automations where the user has already authorized refreshes, apply is allowed only with the exact `dryRunFingerprint` from the immediately preceding dry run.
|
|
68
|
+
- If apply rejects the fingerprint as stale or mismatched, rerun dry-run and ask for approval again for manual runs.
|
|
69
|
+
4. **Apply through the typed command only**:
|
|
70
|
+
- Call `refresh_sender_engagement({ mode:"apply", workspaceId, senderId, tableId?, maxPosts?, maxEngagerPages?, dryRunFingerprint })`.
|
|
71
|
+
- The apply path must materialize/refresh tracked posts, import deduped tracked-post engagers into the campaign table, and preserve product idempotency. Do not use `add_on_demand_leads` or raw table row writes for the same operation.
|
|
72
|
+
5. **Read-only fallback is diagnostic only**:
|
|
73
|
+
- If the typed command is unavailable, use `get_subskill_prompt`/`search_subskill_prompts` and stop with `blocked: missing_refresh_sender_engagement_tool`.
|
|
74
|
+
- `fetch_linkedin_posts` and `fetch_post_engagers` may be used only to explain source availability or diagnose provider issues. They are not an alternate write path.
|
|
75
|
+
6. **Report**: campaigns inspected, target campaign/table, tracked posts materialized/refreshed, engagers scanned, ICP-passing/importable, net-new rows imported, duplicates skipped, and any no-op reason per sender/campaign. If a sender posted nothing in the window, say "no recent posts — nothing to refresh" (that is a truthful no-op, not a failure).
|
|
81
76
|
</objective>
|
|
82
77
|
|
|
83
78
|
<safety>
|
|
84
|
-
- LinkedIn operations here are read-only fetches plus
|
|
79
|
+
- LinkedIn operations here are read-only fetches plus a typed product import into a campaign table. **No messages are generated, approved, scheduled, or sent by this skill.**
|
|
85
80
|
- Respect workspace boundaries: only add leads to campaigns in the active workspace, and only for senders that belong to it.
|
|
86
81
|
- Respect campaign boundaries: only add engagers to the matched sender-owned Post Engagers campaign/table. Do not mix shared-lane engagers into sender-owned campaigns or sender-owned engagers into shared lanes.
|
|
87
82
|
- Cap provider usage per run: at most 5 posts × `fetch_post_engagers` per sender. If the invoking automation wants more, it must say so explicitly.
|
|
88
|
-
- Never call `start_campaign`, `attach_sequence`, `queue_campaign_cells`, `start_campaign_message_preparation`, approval tools,
|
|
83
|
+
- Never call `start_campaign`, `attach_sequence`, `queue_campaign_cells`, `start_campaign_message_preparation`, approval tools, send/schedule tools, or shared-lane mutation tools.
|
|
84
|
+
- Never create campaigns, change campaign status, change sender ownership, approve campaign cells, or start message prep as part of this refresh.
|
|
89
85
|
</safety>
|
|
90
86
|
|
|
91
87
|
<output>
|