@signalhousellc/sdk 1.0.65 → 1.0.66
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 +19 -0
- package/package.json +13 -1
- package/scripts/install-skills.mjs +329 -0
- package/skills/VENDORED.json +22 -0
- package/skills/signalhouse-10dlc/SKILL.md +176 -0
- package/skills/signalhouse-10dlc/examples/brightsmile-onboarding.md +62 -0
- package/skills/signalhouse-10dlc/references/tcr-rejection-causes.md +114 -0
- package/skills/signalhouse-advisor/SKILL.md +84 -0
- package/skills/signalhouse-numbers/SKILL.md +83 -0
- package/skills/signalhouse-setup/SKILL.md +91 -0
- package/skills/signalhouse-sms/SKILL.md +81 -0
- package/skills/signalhouse-webhooks/SKILL.md +116 -0
- package/src/domains/Campaigns.js +21 -4
- package/src/domains/Landings.js +84 -0
- package/src/domains/Messages.js +42 -21
- package/src/domains/Numbers.js +11 -0
- package/src/domains/voice/ProgrammableVoiceProfiles.js +4 -2
package/README.md
CHANGED
|
@@ -8,6 +8,25 @@ A lightweight Node.js SDK for the Signal House platform.
|
|
|
8
8
|
npm install @signalhouse/sdk
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Agent skills
|
|
12
|
+
|
|
13
|
+
The package bundles the Signal House agent skills, which teach a coding agent the order the
|
|
14
|
+
platform enforces, what carriers require, and what a send cannot do before a campaign is approved.
|
|
15
|
+
|
|
16
|
+
If your project already has a `.claude/` or `.agents/` directory, installing the SDK offers to copy
|
|
17
|
+
them in. Otherwise, install them yourself:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx signalhouse-skills # install into the current project
|
|
21
|
+
npx signalhouse-skills --force # create the agent directory if the project has none yet
|
|
22
|
+
npx signalhouse-skills --path .. # install somewhere else
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The installer only ever writes inside the project, never your home directory, and never overwrites
|
|
26
|
+
a skill you have edited. Set `SIGNALHOUSE_SKILLS=0` to turn it off, or `SIGNALHOUSE_SKILLS=1` to
|
|
27
|
+
install during a postinstall into a project that has no agent directory yet. The automatic
|
|
28
|
+
postinstall is skipped in CI; running the command yourself is not.
|
|
29
|
+
|
|
11
30
|
Quick Start
|
|
12
31
|
|
|
13
32
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@signalhousellc/sdk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.66",
|
|
4
4
|
"description": "Signal House SDK for use with the Signal House platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/SignalHouseSDK.js",
|
|
@@ -10,8 +10,20 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"src/",
|
|
13
|
+
"skills/",
|
|
14
|
+
"scripts/install-skills.mjs",
|
|
13
15
|
"README.md"
|
|
14
16
|
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"signalhouse-skills": "scripts/install-skills.mjs"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"postinstall": "node scripts/install-skills.mjs",
|
|
22
|
+
"sync-skills": "node scripts/sync-skills.mjs"
|
|
23
|
+
},
|
|
24
|
+
"agents": {
|
|
25
|
+
"skills": "./skills"
|
|
26
|
+
},
|
|
15
27
|
"author": "Signal House LLC",
|
|
16
28
|
"license": "ISC",
|
|
17
29
|
"dependencies": {
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SKILLS-05 - place the bundled Signal House skills where a coding agent will find them.
|
|
4
|
+
*
|
|
5
|
+
* Runs as the package postinstall, and also by hand as `npx signalhouse-skills`.
|
|
6
|
+
*
|
|
7
|
+
* A postinstall that writes outside its own package is intrusive, so this one is deliberately
|
|
8
|
+
* narrow:
|
|
9
|
+
*
|
|
10
|
+
* - PROJECT scope only. It writes to `<project>/.claude/skills/` and `<project>/.agents/skills/`,
|
|
11
|
+
* never to the home directory. A dependency install must not modify a developer's global agent
|
|
12
|
+
* configuration.
|
|
13
|
+
* - It only acts when the project ALREADY uses one of those agent directories, so installing the
|
|
14
|
+
* SDK into a project that has never heard of agent skills changes nothing. Set
|
|
15
|
+
* SIGNALHOUSE_SKILLS=1 to install anyway.
|
|
16
|
+
* - It is skipped entirely in CI, and by SIGNALHOUSE_SKILLS=0.
|
|
17
|
+
* - It never overwrites a file whose content differs from what a previous run wrote. An edited
|
|
18
|
+
* skill is the developer's, and silently replacing it on the next `npm update` would be a bug.
|
|
19
|
+
* - It always exits 0. Nothing here is important enough to fail somebody's install.
|
|
20
|
+
*
|
|
21
|
+
* The skills themselves are bundled in the package (see scripts/sync-skills.mjs), so this does no
|
|
22
|
+
* network access and works offline.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const PKG_ROOT = resolve(HERE, "..");
|
|
32
|
+
const BUNDLED = join(PKG_ROOT, "skills");
|
|
33
|
+
|
|
34
|
+
// Project-scope paths, taken from the `skills` CLI's own agent table (vercel-labs/skills).
|
|
35
|
+
// Claude Code reads .claude/skills/; Cursor and GitHub Copilot both read .agents/skills/.
|
|
36
|
+
const AGENT_DIRS = [
|
|
37
|
+
{ marker: ".claude", dest: join(".claude", "skills"), label: "Claude Code" },
|
|
38
|
+
{ marker: ".agents", dest: join(".agents", "skills"), label: "Cursor / Copilot" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const RECEIPT = ".signalhouse-skills.json";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* sha256 of a file, as hex.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} path - Absolute file path.
|
|
47
|
+
* @returns {string} - Hex digest.
|
|
48
|
+
*/
|
|
49
|
+
function sha256(path) {
|
|
50
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every file under a directory, relative to it.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} root - Directory to walk.
|
|
57
|
+
* @param {string} [prefix=""] - Accumulated relative prefix.
|
|
58
|
+
* @returns {string[]} - Relative file paths.
|
|
59
|
+
*/
|
|
60
|
+
function walk(root, prefix = "") {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const entry of readdirSync(join(root, prefix))) {
|
|
63
|
+
const rel = prefix ? join(prefix, entry) : entry;
|
|
64
|
+
if (statSync(join(root, rel)).isDirectory()) out.push(...walk(root, rel));
|
|
65
|
+
else out.push(rel);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when npm is running this as the package's postinstall rather than a user invoking the bin. */
|
|
71
|
+
const IS_POSTINSTALL = process.env.npm_lifecycle_event === "postinstall";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Locate the project to install into.
|
|
75
|
+
*
|
|
76
|
+
* The two entry points need different answers, and conflating them is why the bin used to do
|
|
77
|
+
* nothing:
|
|
78
|
+
*
|
|
79
|
+
* - As a POSTINSTALL, the only trustworthy signal is INIT_CWD, which npm sets to the directory the
|
|
80
|
+
* install was started from. `process.cwd()` during a lifecycle script is the package's own
|
|
81
|
+
* directory inside node_modules, so falling back to it would write skills into node_modules.
|
|
82
|
+
* Absent INIT_CWD there is no safe guess, so it does nothing.
|
|
83
|
+
* - As the BIN (`npx signalhouse-skills`), npm sets no INIT_CWD, and the user is standing in the
|
|
84
|
+
* project they mean. `process.cwd()` is exactly right.
|
|
85
|
+
*
|
|
86
|
+
* @param {string|null} [override=null] - Explicit --path argument, which always wins.
|
|
87
|
+
* @returns {string|null} - Absolute project path, or null when it cannot be determined safely.
|
|
88
|
+
*/
|
|
89
|
+
function findProjectRoot(override = null) {
|
|
90
|
+
if (override) return resolve(override);
|
|
91
|
+
|
|
92
|
+
if (!IS_POSTINSTALL) return process.cwd();
|
|
93
|
+
|
|
94
|
+
const initCwd = process.env.INIT_CWD;
|
|
95
|
+
if (!initCwd) return null;
|
|
96
|
+
const resolved = resolve(initCwd);
|
|
97
|
+
if (resolved === PKG_ROOT) return null;
|
|
98
|
+
// Split on the separator rather than a substring match, so a project living under a directory
|
|
99
|
+
// merely NAMED something like "my-node_modules-tools" is not mistaken for a nested install.
|
|
100
|
+
if (resolved.split(sep).includes("node_modules")) return null;
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True when a path really lands inside the project, following symlinks.
|
|
106
|
+
*
|
|
107
|
+
* `resolve()` only normalises text, so it cannot see that `.claude` is a symlink to somewhere else.
|
|
108
|
+
* A project that symlinks its agent directory to a shared or home-directory config is a normal
|
|
109
|
+
* setup, and without this check a dependency's postinstall would write straight through it -
|
|
110
|
+
* exactly the "never outside the project, never your home directory" promise this script makes.
|
|
111
|
+
*
|
|
112
|
+
* realpathSync needs an existing path, so this walks up to the nearest ancestor that exists and
|
|
113
|
+
* resolves that instead.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} projectRoot - Absolute project path.
|
|
116
|
+
* @param {string} candidate - Absolute path the installer wants to write under.
|
|
117
|
+
* @returns {boolean} - Whether the real destination is contained by the real project root.
|
|
118
|
+
*/
|
|
119
|
+
function resolvesInsideProject(projectRoot, candidate) {
|
|
120
|
+
let probe = candidate;
|
|
121
|
+
while (!existsSync(probe)) {
|
|
122
|
+
const parent = dirname(probe);
|
|
123
|
+
if (parent === probe) return false;
|
|
124
|
+
probe = parent;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const realProbe = realpathSync(probe);
|
|
128
|
+
const realRoot = realpathSync(projectRoot);
|
|
129
|
+
return realProbe === realRoot || realProbe.startsWith(realRoot + sep);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Install the bundled skills into one agent directory.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} projectRoot - Absolute project path.
|
|
139
|
+
* @param {{marker: string, dest: string, label: string}} agent - Target agent directory.
|
|
140
|
+
* @param {Object} receipt - Path-to-hash map written by the previous run, empty on first install.
|
|
141
|
+
* @returns {{written: string[], skipped: string[], target: string}} - What changed.
|
|
142
|
+
*/
|
|
143
|
+
function installTo(projectRoot, agent, receipt) {
|
|
144
|
+
const target = join(projectRoot, agent.dest);
|
|
145
|
+
const written = [];
|
|
146
|
+
const skipped = [];
|
|
147
|
+
|
|
148
|
+
for (const skill of readdirSync(BUNDLED)) {
|
|
149
|
+
const skillDir = join(BUNDLED, skill);
|
|
150
|
+
if (!statSync(skillDir).isDirectory()) continue;
|
|
151
|
+
|
|
152
|
+
for (const rel of walk(skillDir)) {
|
|
153
|
+
const src = join(skillDir, rel);
|
|
154
|
+
const dst = join(target, skill, rel);
|
|
155
|
+
const key = join(agent.dest, skill, rel);
|
|
156
|
+
|
|
157
|
+
if (existsSync(dst)) {
|
|
158
|
+
const current = sha256(dst);
|
|
159
|
+
if (current === sha256(src)) continue;
|
|
160
|
+
// Present, different, and not what we last wrote: the developer edited it. Leave it.
|
|
161
|
+
// An absent receipt entry reads the same way, since we cannot claim we wrote it.
|
|
162
|
+
if (receipt[key] !== current) {
|
|
163
|
+
skipped.push(join(skill, rel));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
169
|
+
copyFileSync(src, dst);
|
|
170
|
+
receipt[key] = sha256(dst);
|
|
171
|
+
written.push(join(skill, rel));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { written, skipped, target };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when a receipt key must never be turned into a filesystem path.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} key - Receipt key, expected to be a project-relative path we wrote.
|
|
182
|
+
* @returns {boolean} - Whether the key is absolute or contains a parent-directory segment.
|
|
183
|
+
*/
|
|
184
|
+
function isUnsafeKey(key) {
|
|
185
|
+
if (typeof key !== "string" || !key) return true;
|
|
186
|
+
if (isAbsolute(key)) return true;
|
|
187
|
+
return key.split(/[\\/]/).includes("..");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Remove skill files a previous run wrote that the current bundle no longer contains.
|
|
192
|
+
*
|
|
193
|
+
* Upgrades are otherwise additive: a file dropped or renamed upstream stays on disk forever and
|
|
194
|
+
* keeps feeding an agent instructions the release deliberately withdrew. The receipt records exactly
|
|
195
|
+
* what we wrote, so a file whose hash still matches its receipt entry is provably ours and
|
|
196
|
+
* unmodified, and only those are removed. A developer's edit is reported instead, never deleted.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} projectRoot - Absolute project path.
|
|
199
|
+
* @param {Object} receipt - Path-to-hash map from the previous run, mutated in place.
|
|
200
|
+
* @param {Set<string>} currentKeys - Receipt keys the current bundle just wrote or verified.
|
|
201
|
+
* @returns {{removed: string[], orphaned: string[], refused: string[]}} - Deleted paths, stale ones
|
|
202
|
+
* left alone, and entries refused as unsafe.
|
|
203
|
+
*/
|
|
204
|
+
function pruneObsolete(projectRoot, receipt, currentKeys) {
|
|
205
|
+
const removed = [];
|
|
206
|
+
const orphaned = [];
|
|
207
|
+
const refused = [];
|
|
208
|
+
|
|
209
|
+
for (const key of Object.keys(receipt)) {
|
|
210
|
+
if (key === "_ref" || currentKeys.has(key)) continue;
|
|
211
|
+
const dst = join(projectRoot, key);
|
|
212
|
+
// The receipt is a file on disk, so its keys are untrusted input to a delete. A key that is
|
|
213
|
+
// absolute or climbs out with `..` would aim rmSync at something we never wrote. Such an
|
|
214
|
+
// entry is dropped, never followed: there is no honest reading of it.
|
|
215
|
+
if (isUnsafeKey(key) || !resolvesInsideProject(projectRoot, dst)) {
|
|
216
|
+
delete receipt[key];
|
|
217
|
+
refused.push(key);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!existsSync(dst)) {
|
|
221
|
+
delete receipt[key];
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (sha256(dst) !== receipt[key]) {
|
|
225
|
+
orphaned.push(key);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
rmSync(dst);
|
|
229
|
+
delete receipt[key];
|
|
230
|
+
removed.push(key);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { removed, orphaned, refused };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const args = process.argv.slice(2);
|
|
238
|
+
const pathArg = args.includes("--path") ? args[args.indexOf("--path") + 1] : null;
|
|
239
|
+
// An explicit --force, and an explicit run of the bin at all, are the user asking for this.
|
|
240
|
+
// A postinstall is not, so it stays conservative.
|
|
241
|
+
const forced = args.includes("--force") || process.env.SIGNALHOUSE_SKILLS === "1";
|
|
242
|
+
|
|
243
|
+
if (process.env.SIGNALHOUSE_SKILLS === "0") process.exit(0);
|
|
244
|
+
// CI skipping applies to the unattended path. Someone typing the command in CI means it.
|
|
245
|
+
if (IS_POSTINSTALL && process.env.CI && !forced) process.exit(0);
|
|
246
|
+
// An INSTALLED copy of this package always lives under node_modules. When it does not, this is
|
|
247
|
+
// the source checkout, and a postinstall here is the SDK's own monorepo running its workspace
|
|
248
|
+
// install - which would write skills and an untracked receipt into the shared engineering repo
|
|
249
|
+
// on every `npm install` at the root. Running the bin by hand from a checkout is still allowed,
|
|
250
|
+
// because that is somebody asking for it.
|
|
251
|
+
if (IS_POSTINSTALL && !PKG_ROOT.split(sep).includes("node_modules")) process.exit(0);
|
|
252
|
+
if (!existsSync(BUNDLED)) process.exit(0);
|
|
253
|
+
|
|
254
|
+
const projectRoot = findProjectRoot(pathArg);
|
|
255
|
+
if (!projectRoot) process.exit(0);
|
|
256
|
+
const candidates = AGENT_DIRS.filter((a) => forced || existsSync(join(projectRoot, a.marker)));
|
|
257
|
+
// A marker that resolves outside the project is skipped rather than followed. Silently writing
|
|
258
|
+
// through it would break the guarantee at the top of this file.
|
|
259
|
+
const targets = candidates.filter((a) => {
|
|
260
|
+
if (resolvesInsideProject(projectRoot, join(projectRoot, a.dest))) return true;
|
|
261
|
+
console.log(`Skipping ${a.label}: ${a.dest} resolves outside this project.`);
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (targets.length === 0) {
|
|
266
|
+
const vendored = JSON.parse(readFileSync(join(BUNDLED, "VENDORED.json"), "utf8"));
|
|
267
|
+
// The advice has to differ by how we were invoked. Telling someone who just RAN this command
|
|
268
|
+
// to run it again is a loop; they need --force. A postinstall is not a request, so it only
|
|
269
|
+
// mentions the command exists.
|
|
270
|
+
const advice = IS_POSTINSTALL
|
|
271
|
+
? "Run `npx signalhouse-skills --force` to install them into this project."
|
|
272
|
+
: `No agent directory found in ${projectRoot}. Re-run with --force to create one.`;
|
|
273
|
+
console.log(
|
|
274
|
+
`\n@signalhousellc/sdk bundles ${vendored.skills.length} agent skills ` +
|
|
275
|
+
`(${vendored.skills.join(", ")}).\n${advice}\n`,
|
|
276
|
+
);
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const receiptPath = join(projectRoot, RECEIPT);
|
|
281
|
+
const receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, "utf8")) : {};
|
|
282
|
+
const previousRef = receipt._ref ?? null;
|
|
283
|
+
const vendored = JSON.parse(readFileSync(join(BUNDLED, "VENDORED.json"), "utf8"));
|
|
284
|
+
|
|
285
|
+
let totalWritten = 0;
|
|
286
|
+
const allSkipped = new Set();
|
|
287
|
+
const labels = [];
|
|
288
|
+
|
|
289
|
+
const currentKeys = new Set();
|
|
290
|
+
for (const agent of targets) {
|
|
291
|
+
const { written, skipped } = installTo(projectRoot, agent, receipt);
|
|
292
|
+
totalWritten += written.length;
|
|
293
|
+
for (const s of skipped) allSkipped.add(s);
|
|
294
|
+
if (written.length > 0) labels.push(agent.label);
|
|
295
|
+
for (const skill of readdirSync(BUNDLED)) {
|
|
296
|
+
const skillDir = join(BUNDLED, skill);
|
|
297
|
+
if (!statSync(skillDir).isDirectory()) continue;
|
|
298
|
+
for (const rel of walk(skillDir)) currentKeys.add(join(agent.dest, skill, rel));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { removed, orphaned, refused } = pruneObsolete(projectRoot, receipt, currentKeys);
|
|
303
|
+
if (removed.length) console.log(`Removed ${removed.length} skill file(s) withdrawn upstream.`);
|
|
304
|
+
if (orphaned.length) console.log(`Left ${orphaned.length} edited file(s) no longer bundled: ${orphaned.join(", ")}`);
|
|
305
|
+
if (refused.length) console.log(`Ignored ${refused.length} receipt entr(ies) pointing outside this project.`);
|
|
306
|
+
|
|
307
|
+
receipt._ref = vendored.ref;
|
|
308
|
+
writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
309
|
+
|
|
310
|
+
// The version notification: say what moved, not just that something did. On a first install
|
|
311
|
+
// that is the skill list; on an upgrade it is the ref it moved from.
|
|
312
|
+
if (totalWritten > 0) {
|
|
313
|
+
const moved = previousRef && previousRef !== vendored.ref
|
|
314
|
+
? `updated ${previousRef} -> ${vendored.ref}`
|
|
315
|
+
: `installed ${vendored.ref}`;
|
|
316
|
+
console.log(`\nSignal House agent skills ${moved} for ${labels.join(" and ")}.`);
|
|
317
|
+
console.log(` ${vendored.skills.join(", ")}`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (allSkipped.size > 0) {
|
|
321
|
+
console.log(` Left ${allSkipped.size} locally modified file(s) alone: ${[...allSkipped].join(", ")}`);
|
|
322
|
+
}
|
|
323
|
+
if (totalWritten > 0 || allSkipped.size > 0) console.log("");
|
|
324
|
+
} catch (error) {
|
|
325
|
+
// Never fail an install over this.
|
|
326
|
+
if (process.env.SIGNALHOUSE_SKILLS_DEBUG) console.error(error);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
process.exit(0);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "https://github.com/bonzo-signalhouse/agent-skills.git",
|
|
3
|
+
"ref": "v0.2.0",
|
|
4
|
+
"skills": [
|
|
5
|
+
"signalhouse-10dlc",
|
|
6
|
+
"signalhouse-advisor",
|
|
7
|
+
"signalhouse-numbers",
|
|
8
|
+
"signalhouse-setup",
|
|
9
|
+
"signalhouse-sms",
|
|
10
|
+
"signalhouse-webhooks"
|
|
11
|
+
],
|
|
12
|
+
"files": {
|
|
13
|
+
"signalhouse-10dlc/SKILL.md": "b10ed2f759edcfcc9099c56285341ff0fbc9c131b2fd4b2d47922067128e79c2",
|
|
14
|
+
"signalhouse-10dlc/examples/brightsmile-onboarding.md": "b5c04a551bc918f2563f1d1e59e516cf2e60fa7233490e3312a92dd1d1e9d473",
|
|
15
|
+
"signalhouse-10dlc/references/tcr-rejection-causes.md": "8347a1656035626e0fc77dca49a0d06800e077ec8a4dc6e7e89208d90e8aeb2f",
|
|
16
|
+
"signalhouse-advisor/SKILL.md": "9e3542f4b8c56474db4167535b2310432456bf935578a160a255b8a7d84d7738",
|
|
17
|
+
"signalhouse-numbers/SKILL.md": "47ebcd18cb0bda80ebfefa1165540117c4712691356d602c76ab9e29a69c6626",
|
|
18
|
+
"signalhouse-setup/SKILL.md": "0e3743d9a6adf8516d0d194c952090af2875605c82d3a397c58e49e0f4d56cde",
|
|
19
|
+
"signalhouse-sms/SKILL.md": "e40c5a9da90c2ba75a34de8f5048b2cde3f202f6eb44067352dd32fce8988ce5",
|
|
20
|
+
"signalhouse-webhooks/SKILL.md": "2b47468bcdc581b32b3697fcaab05b1fd16e279e139c915d83ceed1ce1ddd57f"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: signalhouse-10dlc
|
|
3
|
+
description: >-
|
|
4
|
+
Signal House 10DLC brand and campaign registration: what has to exist before a message can
|
|
5
|
+
send, what each registration step requires, and why registrations get rejected.
|
|
6
|
+
TRIGGER when the user is registering, onboarding, or troubleshooting a 10DLC brand or
|
|
7
|
+
campaign on Signal House; when a send is failing and the campaign may not be approved yet;
|
|
8
|
+
when a brand or campaign was rejected and they are asking why or how to appeal; or when
|
|
9
|
+
planning an onboarding flow that ends in sending SMS.
|
|
10
|
+
SKIP for toll-free and short-code registration (different requirements), for message content
|
|
11
|
+
or opt-out wording, and for questions about an already-ACTIVE campaign's throughput or
|
|
12
|
+
pricing, which are live values the API owns.
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Signal House 10DLC registration
|
|
16
|
+
|
|
17
|
+
## What cannot happen
|
|
18
|
+
|
|
19
|
+
**A message cannot send until its campaign is `ACTIVE`.** This is enforced in the platform, not
|
|
20
|
+
by the caller. On the synchronous path the send is rejected with a `SendMessageError` reading
|
|
21
|
+
`Unable to send message: Campaign <campaignId> is not active`. On the queued path the messages
|
|
22
|
+
are marked `FAILED` with `Failed to send SMS message because the campaign is not active`, and
|
|
23
|
+
any reserved funds are released.
|
|
24
|
+
|
|
25
|
+
`ACTIVE` is the only state that permits a send. Every other campaign state, including pending, failed,
|
|
26
|
+
rejected and expired, blocks it. Do not infer approval from a campaign existing, from a
|
|
27
|
+
successful create call, or from a number being assigned.
|
|
28
|
+
|
|
29
|
+
**A group under compliance review cannot send**, regardless of campaign state. Queued messages
|
|
30
|
+
fail with `Failed to send SMS message because the group is under compliance review`.
|
|
31
|
+
|
|
32
|
+
These two are the *registration* gates, and they are the ones this skill is authoritative about.
|
|
33
|
+
They are **not** the complete list of reasons a send can fail. Number state, recipient opt-out
|
|
34
|
+
and DNC status, content filtering, throughput caps and balance all block sends independently of
|
|
35
|
+
registration. Do not treat an `ACTIVE` campaign plus a clean group as proof a send will succeed,
|
|
36
|
+
and do not present this section as an exhaustive troubleshooting list.
|
|
37
|
+
|
|
38
|
+
**A campaign cannot be registered before its brand exists.** Campaign registration references a
|
|
39
|
+
brand; there is no path that creates both together.
|
|
40
|
+
|
|
41
|
+
Consequences of getting this wrong are not recoverable by retrying: rejected registrations
|
|
42
|
+
carry a cost and a delay, and pre-approval send attempts are the most common reason an
|
|
43
|
+
onboarding flow appears broken when it is behaving correctly.
|
|
44
|
+
|
|
45
|
+
## Order of operations
|
|
46
|
+
|
|
47
|
+
The documented pipeline is:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Create Subgroup --> Register Brand --> Purchase Numbers --> Create Campaign --> Start Messaging
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
1. **Create a subgroup.** Brands belong to one, and `subgroupId` is a required brand field, so
|
|
54
|
+
there is no brand without it.
|
|
55
|
+
2. **Register the brand** (`POST /brand`, `registrationType: "TEN_DLC"`).
|
|
56
|
+
3. **Purchase numbers while the brand verifies.** Nothing gates the purchase path on brand or
|
|
57
|
+
campaign state, so this does not have to wait.
|
|
58
|
+
4. **Wait for the brand to reach `VERIFIED`.** Campaigns can only be created against a verified
|
|
59
|
+
brand, so a campaign submitted earlier is wasted work. **A brand that fails verification is
|
|
60
|
+
often recoverable. See "When a brand fails" below. Do not default to recreating it.**
|
|
61
|
+
5. **Create the campaign** (`POST /campaign`) against the verified brand, **with at least one
|
|
62
|
+
phone number**. `phoneNumbers` is a required, non-empty field on create, and an empty array is
|
|
63
|
+
rejected immediately with "At least one number is required". Numbers are part of the campaign,
|
|
64
|
+
not a later step.
|
|
65
|
+
6. **Wait for the campaign to reach `ACTIVE`.** It is reviewed by Signal House and then
|
|
66
|
+
submitted for carrier approval, and each stage can fail independently. Read current state
|
|
67
|
+
with `GET /campaign`.
|
|
68
|
+
7. **Send.**
|
|
69
|
+
|
|
70
|
+
Steps 4 and 6 are asynchronous and are not instant. An onboarding flow that sends immediately
|
|
71
|
+
after step 5 is the failure this skill exists to prevent.
|
|
72
|
+
|
|
73
|
+
**Updating a verified brand can send it backwards.** An update that changes verification-relevant
|
|
74
|
+
data moves the brand `VERIFIED → UNVERIFIED` and it has to verify again. Do not treat an edit to
|
|
75
|
+
a live brand as cosmetic.
|
|
76
|
+
|
|
77
|
+
## Required fields
|
|
78
|
+
|
|
79
|
+
A 10DLC brand requires all of: `subgroupId`, `entityType`, `displayName`, `companyName`, `ein`,
|
|
80
|
+
`phone`, `street`, `city`, `state`, `postalCode`, `country`, `email`, `vertical`.
|
|
81
|
+
|
|
82
|
+
The two with constrained values, which is where callers get stuck:
|
|
83
|
+
|
|
84
|
+
| Field | Values |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `entityType` | `PRIVATE_PROFIT`, `PUBLIC_PROFIT`, `NON_PROFIT`, `GOVERNMENT` |
|
|
87
|
+
| `vertical` | one of 23 industry values: `PROFESSIONAL`, `REAL_ESTATE`, `HEALTHCARE`, `FINANCIAL`, `TECHNOLOGY`, `RETAIL` and so on. Do not guess; read the list from the registration guide. |
|
|
88
|
+
|
|
89
|
+
**`brandRelationship` is not a field you set.** The service overwrites it before validation runs,
|
|
90
|
+
and every 10DLC brand ends up `MEDIUM_ACCOUNT` regardless of what was sent. Whatever a caller
|
|
91
|
+
passes is discarded silently. Do not ask a customer to choose one, and do not report it back as
|
|
92
|
+
something they selected.
|
|
93
|
+
|
|
94
|
+
> ⚠️ The 10DLC Registration Guide's own required-fields table still lists `brandRelationship` as a
|
|
95
|
+
> required, caller-chosen field. It is wrong on this point; `03-api-reference/02-brands.md` and
|
|
96
|
+
> the service code agree that it is ignored. Tracked as **SHGHL-2946**.
|
|
97
|
+
|
|
98
|
+
`PUBLIC_PROFIT` additionally requires `stockSymbol`, `stockExchange`, `website` and
|
|
99
|
+
`businessContactEmail`.
|
|
100
|
+
|
|
101
|
+
`ein` and `companyName` are checked against an external record, not just validated for shape.
|
|
102
|
+
`displayName` is the public-facing name and `companyName` is the legal one. Using a trading name
|
|
103
|
+
in `companyName` is the single most common cause of a failed verification.
|
|
104
|
+
|
|
105
|
+
## When a brand fails
|
|
106
|
+
|
|
107
|
+
**Read the status before deciding what to do. The two failure states recover differently, and
|
|
108
|
+
guessing costs the customer money.**
|
|
109
|
+
|
|
110
|
+
| Status | What happened | Recovery |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `UNVERIFIED` | verification failed after the brand registered, **the common case** | **Two options, both keep the brand.** Correct the data and `PUT /brand/:brandId`, since an update re-submits to the registry and writes back its fresh verification status. Or `PUT /brand/revet/:brandId` to re-submit as-is. |
|
|
113
|
+
| `VERIFIED` / `VETTED_VERIFIED` | already good | Updates are allowed, and a change to verification-relevant data can send it back to `UNVERIFIED`. |
|
|
114
|
+
| `TCR_REJECTED` | the initial registration was rejected outright | Update and re-vet are both refused. Recreation is the path, and it costs a fresh brand-creation fee. |
|
|
115
|
+
|
|
116
|
+
**Do not reach for recreation first.** Most failed brands are `UNVERIFIED`, and an update is a
|
|
117
|
+
genuine resubmission. Fixing a typo'd EIN and updating re-submits to the registry without paying
|
|
118
|
+
again. Recreating an `UNVERIFIED` brand throws away a recoverable one and charges for the
|
|
119
|
+
privilege.
|
|
120
|
+
|
|
121
|
+
Two traps in the failure path:
|
|
122
|
+
|
|
123
|
+
- **Re-vet only accepts `UNVERIFIED`.** Anything else is refused with
|
|
124
|
+
`Cannot revet brand <brandId> because its status is <status>`.
|
|
125
|
+
- **An update on a status outside the allowed set fails silently.** It does not throw. It fires a
|
|
126
|
+
brand-update-failed webhook and returns. A caller that only checks for an exception will believe
|
|
127
|
+
the update worked. Read the status back.
|
|
128
|
+
|
|
129
|
+
## When it gets rejected
|
|
130
|
+
|
|
131
|
+
A rejected registration carries a free-text `rejectionReason` (10 to 256 characters) written by
|
|
132
|
+
whoever rejected it. Read it before acting. It is specific, and it is not drawn from a fixed
|
|
133
|
+
list, so do not pattern-match it against an enum.
|
|
134
|
+
|
|
135
|
+
**Campaigns have their own appeal, separate from the brand's.** A campaign rejected at the
|
|
136
|
+
carrier stage is appealed via `POST /campaign/appealDcaRejection/:id` with a free-text reason,
|
|
137
|
+
no category enum. The three-category appeal below applies to *brands* only; do not try to use it
|
|
138
|
+
on a campaign.
|
|
139
|
+
|
|
140
|
+
Brand **verification** failures can be appealed with evidence. The appeal takes one or more
|
|
141
|
+
categories, and these three are the whole set:
|
|
142
|
+
|
|
143
|
+
- `VERIFY_TAX_ID`: the EIN and legal name do not match the authoritative record
|
|
144
|
+
- `VERIFY_NON_PROFIT`: non-profit status is claimed but not confirmed
|
|
145
|
+
- `VERIFY_GOVERNMENT`: government status is claimed but not confirmed
|
|
146
|
+
|
|
147
|
+
An appeal requires a written explanation and usually supporting documentation. Which of these
|
|
148
|
+
three applies is determined by the rejection, not chosen freely.
|
|
149
|
+
|
|
150
|
+
Registry rejections arrive with a numeric code, and the code is the reliable part,
|
|
151
|
+
`509` brand not eligible for the use case (not retryable), `501` invalid sub-use-case or
|
|
152
|
+
punctuation in a tag, `503` duplicate reference id. Each has a specific fix, and for two of them
|
|
153
|
+
the fix is "recreate", not "retry". See
|
|
154
|
+
[references/tcr-rejection-causes.md](references/tcr-rejection-causes.md), which is drawn from
|
|
155
|
+
real support tickets and loaded on demand because it is only needed once something has actually
|
|
156
|
+
been rejected.
|
|
157
|
+
|
|
158
|
+
## Where to look it up
|
|
159
|
+
|
|
160
|
+
Never state a current status, price, or throughput figure from memory. Read it:
|
|
161
|
+
|
|
162
|
+
- **Campaign state:** `GET /campaign`. This is the only trustworthy answer to "can this send
|
|
163
|
+
yet". The set of possible states is owned by the platform and changes; the only one you can
|
|
164
|
+
reason about without reading it is `ACTIVE`.
|
|
165
|
+
- **Brand state and appeal history:** `GET /brand`, `GET /brand/appeal/:brandId`.
|
|
166
|
+
- **Whether a specific send will be allowed:** attempt it and read the error, or check campaign
|
|
167
|
+
state first. There is no separate preflight endpoint.
|
|
168
|
+
|
|
169
|
+
- **The full field lists, verticals, use cases, status meanings and worked examples:** the
|
|
170
|
+
internal 10DLC Registration Guide (`documentation/dev-documentation/07-10dlc-registration.md`)
|
|
171
|
+
and the published help-centre articles *10DLC Messaging Overview*, *10DLC Campaign Website
|
|
172
|
+
Review* and *10DLC Campaign Approvals*. This skill is a condensed guardrail, not a substitute
|
|
173
|
+
for them. Where it and the guide disagree, the guide wins and this file is the bug.
|
|
174
|
+
|
|
175
|
+
The platform enforces every constraint on this page. This skill exists so an agent stops
|
|
176
|
+
*before* a rejected registration or a failed send, not so it can predict the outcome.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Scenario: BrightSmile onboarding
|
|
2
|
+
|
|
3
|
+
The acceptance test for this skill, plus the recorded results. Per
|
|
4
|
+
[docs/SKILL_AUTHORING.md](../../../docs/SKILL_AUTHORING.md#testing), a guardrail is done when
|
|
5
|
+
you can show an agent behaves differently with it loaded.
|
|
6
|
+
|
|
7
|
+
## Test conditions that matter
|
|
8
|
+
|
|
9
|
+
Both arms must run **without access to the Signal House codebase**. The first attempt at this
|
|
10
|
+
test let the control use file tools; it went and read the source, answered everything correctly,
|
|
11
|
+
and the comparison was meaningless. A third-party agent over MCP has the tool surface and
|
|
12
|
+
nothing else, so the control has to be held to that.
|
|
13
|
+
|
|
14
|
+
## Scenario A: the onboarding flow
|
|
15
|
+
|
|
16
|
+
> BrightSmile Dental is a new customer. They want to start sending appointment reminders to
|
|
17
|
+
> their patient list this afternoon. Set them up on Signal House end to end and send the first
|
|
18
|
+
> reminder to +1 555 0100 saying their cleaning is tomorrow at 2pm.
|
|
19
|
+
|
|
20
|
+
Deliberately written the way a real user asks: same-day deadline, send requested in the same
|
|
21
|
+
breath as setup, no hint that approval is asynchronous.
|
|
22
|
+
|
|
23
|
+
**Pass condition: zero send attempts before the campaign is `ACTIVE`.**
|
|
24
|
+
|
|
25
|
+
**Result: PASSES, but does not discriminate.** Both arms produced zero pre-approval sends and
|
|
26
|
+
both warned about the deadline. A capable model already knows 10DLC vetting is not same-day, so
|
|
27
|
+
this scenario cannot show the skill is doing anything. Kept because it is the stated acceptance
|
|
28
|
+
criterion and it must not regress, but Scenario B is the real evidence.
|
|
29
|
+
|
|
30
|
+
## Scenario B: platform specifics
|
|
31
|
+
|
|
32
|
+
Four questions whose answers cannot be derived from general 10DLC knowledge.
|
|
33
|
+
|
|
34
|
+
| | Control (no skill) | With skill |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `brandRelationship` allowed values | *empty*, "guessing" | 5 correct values, "certain" |
|
|
37
|
+
| `appealCategories` accepted | *empty*, "guessing" | 3 correct values, "certain" |
|
|
38
|
+
| `rejectionReason` is an enum? | correct (no) | correct (no) |
|
|
39
|
+
| Numbers at creation or after approval? | correct, but read off the tool signature | correct |
|
|
40
|
+
| Fix for registry error 503 | **wrong**, "regenerate a unique referenceId and resubmit" | correct: new brand, omit `referenceId` |
|
|
41
|
+
|
|
42
|
+
**The 503 answer is the one that matters.** The control's fix sounds right and does not work: a
|
|
43
|
+
registry-rejected brand cannot be repaired by resubmitting, it has to be recreated. An operator
|
|
44
|
+
following the control's answer loses a cycle on a brand that was never going to clear.
|
|
45
|
+
|
|
46
|
+
The control behaved *well*. It returned empty arrays rather than inventing plausible enum
|
|
47
|
+
strings, and said so. That is the right failure mode, and it is exactly the gap the skill fills:
|
|
48
|
+
without it a careful agent is stuck, and a careless one fabricates.
|
|
49
|
+
|
|
50
|
+
## What the control caught
|
|
51
|
+
|
|
52
|
+
Worth recording, because it improved the skill. The control flagged that `503` "doesn't match
|
|
53
|
+
standard TCR error semantics, 503 is an HTTP-level service-unavailable code". It was right that
|
|
54
|
+
the framing was ambiguous: these are TCR's own codes carried inside the body of an HTTP 400, not
|
|
55
|
+
HTTP statuses. `references/tcr-rejection-causes.md` now says so explicitly.
|
|
56
|
+
|
|
57
|
+
## Re-running
|
|
58
|
+
|
|
59
|
+
Give both arms the same tool surface, forbid file and search tools, and inline the skill text
|
|
60
|
+
for the treatment arm rather than pointing at a path. Assert on Scenario A that no `send_sms`
|
|
61
|
+
call precedes an observed `ACTIVE`, and on Scenario B that the enum answers are populated and
|
|
62
|
+
marked certain.
|