@signalhousellc/sdk 1.0.65 → 1.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -36,3 +55,13 @@ For full API reference and advanced usage, visit https://api.signalhouse.io
36
55
 
37
56
  License
38
57
  ISC © Signal House
58
+
59
+
60
+ ### Geographic availability search
61
+
62
+ The availability path supports optional `city` as a case-insensitive prefix within an explicit `country` (`CA` or `US`) and `state` (province/state code). City must be supplied with both country and state. Example: `getAvailablePhoneNumbers({ country: "CA", state: "QC", city: "Charny", npa: "367", nxx: "883", limit: 10 })`. Canadian province, city, NPA and NXX matches are checked before the result limit; unrelated substring matches are excluded. Availability may change before purchase.
63
+
64
+
65
+ Canadian message estimates return per-recipient `phoneNumber`, `rate`, `amount` and `fallback`, plus segment count and total in microdollars. They use cached carrier information without exposing carrier names or making a paid lookup. Rates remain 75,500 for standard carriers and 81,000 for Ice Wireless/Iristel or unresolved carriers; MMS/group MMS use one segment per recipient.
66
+
67
+ Available-number searches return `{ numbers, numberCount? }`. `numberCount` is omitted when the total is unknown, including Canadian geographic and US city searches; the page length is not a total. These filtered searches have a 20-second discovery deadline and return HTTP 503 when incomplete. Narrow the location/NPA/NXX or retry later. Successful Infobip samples may be cached for 15 seconds across pages; purchase rechecks availability.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalhousellc/sdk",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
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.