@sellable/mcp 0.1.32 → 0.1.34

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/dist/server.js CHANGED
@@ -412,7 +412,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
412
412
  result = await fetchLinkedInPosts(args?.linkedinUrl, args?.limit);
413
413
  break;
414
414
  case "fetch_linkedin_profile":
415
- result = await fetchLinkedInProfile(args?.linkedinUrl);
415
+ result = await fetchLinkedInProfile(args?.linkedinUrl, {
416
+ full: args?.full === true,
417
+ });
416
418
  break;
417
419
  case "fetch_company":
418
420
  result = await fetchCompany(args?.companyUrl);
@@ -18,6 +18,14 @@ export type AuthStatus = {
18
18
  message: string;
19
19
  guidance: string;
20
20
  };
21
+ /**
22
+ * Verbatim instruction for the AGENT (not the user) when auth is missing.
23
+ * The agent MUST follow this verbatim and MUST NOT show error.guidance to
24
+ * the user — that's a non-interactive fallback. This field is the single
25
+ * source of truth for the FTUX magic-link flow so even a stale skill copy
26
+ * does the right thing.
27
+ */
28
+ agentInstruction?: string;
21
29
  };
22
30
  export declare const authToolDefinitions: {
23
31
  name: string;
@@ -22,7 +22,7 @@ function appendUpdateNotice(notice, update) {
22
22
  export const authToolDefinitions = [
23
23
  {
24
24
  name: "get_auth_status",
25
- description: "Verify Sellable authentication early. Call this before any campaign work. Also performs a cached Sellable package update check. Returns ok=false with clear guidance when the token is missing, revoked, or the active workspace is not set. IMPORTANT: When auth succeeds, ALWAYS tell the user the _userNotice message so they know which workspace is active and whether an update is available.",
25
+ description: "Verify Sellable authentication early. Call this before any campaign work. Also performs a cached Sellable package update check. Returns ok=false with clear guidance when the token is missing, revoked, or the active workspace is not set. IMPORTANT: When auth succeeds, ALWAYS tell the user the _userNotice message so they know which workspace is active and whether an update is available. CRITICAL: When auth FAILS with a missing/invalid token (error.type is 'config' or 'auth'), the response includes an `agentInstruction` field with the verbatim FTUX magic-link signup flow. Follow agentInstruction VERBATIM. Do NOT show the user error.guidance — that's a fallback for non-interactive contexts only.",
26
26
  inputSchema: {
27
27
  type: "object",
28
28
  properties: {},
@@ -110,6 +110,36 @@ export async function getAuthStatus() {
110
110
  };
111
111
  }
112
112
  catch (error) {
113
+ // FTUX directive: when the user is not yet signed in, tell the AGENT
114
+ // (not the user) to run the magic-link CLI handoff verbatim. Treat this
115
+ // as the single source of truth so even a stale skill does the right
116
+ // thing instead of dumping the technical fallback guidance.
117
+ const ftuxAgentInstruction = "AGENT INSTRUCTION (verbatim, do not paraphrase, do not show the user `error.guidance`): " +
118
+ "The user has not signed in to Sellable yet. Run the magic-link CLI handoff. " +
119
+ "1) Say to the user verbatim:\\n" +
120
+ "`Welcome to Sellable! I'll help you launch a LinkedIn outbound campaign right here, all via chat — leads, messages, the whole thing.\\n\\n" +
121
+ "First, let's connect your Sellable account:\\n\\n" +
122
+ " 1. Drop your email below\\n" +
123
+ " 2. I'll send a magic login link to your inbox\\n" +
124
+ " 3. Click it, come back here, and we'll keep going\\n\\n" +
125
+ "What email should I use?` " +
126
+ "2) Wait for the user to type their email in normal chat (do NOT use AskUserQuestion / request_user_input). " +
127
+ "3) Call `mcp__sellable__start_cli_login({ email })` with that email. " +
128
+ "4) On `ok: true`, say verbatim (substituting the email exactly as typed):\\n" +
129
+ "`Magic link sent to {email}.\\n\\n" +
130
+ "─────────────────────────────────────────────\\n" +
131
+ " Your turn — check your inbox\\n" +
132
+ "─────────────────────────────────────────────\\n\\n" +
133
+ " 1. Open the email from Sellable\\n" +
134
+ " 2. Click the magic link\\n" +
135
+ " 3. Come back here when you're done\\n\\n" +
136
+ "I'll be waiting right here.\\n\\n" +
137
+ " (If your team already uses Sellable, ask an admin to invite you into their shared workspace instead — that gets you straight in.)` " +
138
+ "5) Call `mcp__sellable__wait_for_cli_login({ sessionId })` using the sessionId returned by start_cli_login. " +
139
+ "6) If the result is `error.type === 'tool_timeout_guard'`, IMMEDIATELY re-call wait_for_cli_login with the SAME sessionId — do not narrate, do not call start_cli_login again. Loop until you get a different result. " +
140
+ "7) On `ok: true`, the user is signed in and `~/.sellable/config.json` has been written. Branch on `isReturningUser`: " +
141
+ "if true, say `You're in — {activeWorkspaceName} workspace, ready to roll.\\n\\nNow — paste the LinkedIn profile URL of the person you'll be sending campaigns from. Usually that's you (the founder), or whoever's voice the messages should sound like.\\n\\ne.g. https://www.linkedin.com/in/your-handle`; " +
142
+ "if false, say `You're set up — your {activeWorkspaceName} workspace is ready.\\n\\nNow — paste the LinkedIn profile URL of the person you'll be sending campaigns from. Usually that's you (the founder), or whoever's voice the messages should sound like.\\n\\ne.g. https://www.linkedin.com/in/your-handle`";
113
143
  if (error instanceof SellableApiError && error.isAuthError) {
114
144
  return {
115
145
  ...base,
@@ -120,6 +150,7 @@ export async function getAuthStatus() {
120
150
  guidance: error.guidance ||
121
151
  `Update ${configPath} with a valid token, then retry get_auth_status.`,
122
152
  },
153
+ agentInstruction: ftuxAgentInstruction,
123
154
  };
124
155
  }
125
156
  const message = error instanceof Error ? error.message : String(error);
@@ -136,6 +167,7 @@ export async function getAuthStatus() {
136
167
  message,
137
168
  guidance,
138
169
  },
170
+ agentInstruction: isConfigError ? ftuxAgentInstruction : undefined,
139
171
  };
140
172
  }
141
173
  }
@@ -123,7 +123,7 @@ export const campaignToolDefinitions = [
123
123
  },
124
124
  {
125
125
  name: "create_campaign",
126
- description: 'Create a new campaign offer OR resume an existing one. Low-level write tool: load create-campaign workflow instructions first via get_subskill_prompt({ subskillName: "create-campaign" }) (or bootstrap_create_campaign + nextStep). If campaignId is provided, this tool returns the watchUrl + state for that campaign instead of creating a new campaign.\n\nINPUTS:\n- Pass EITHER `clientProspectId` (if you already have a prospect ID from a prior workflow) OR `senderLinkedinUrl` (the sender\'s LinkedIn profile URL preferred for net-new campaigns).\n- Passing both is fine; `clientProspectId` wins.\n- Passing neither returns a preflight error.\n\nPREREQUISITES:\n- The /research-sender skill must have been run for this sender (sets the campaign brief).',
126
+ description: 'Create a new campaign offer OR resume an existing one. Low-level write tool: load create-campaign workflow instructions first via get_subskill_prompt({ subskillName: "create-campaign" }) (or bootstrap_create_campaign + nextStep). If campaignId is provided, this tool returns the watchUrl + state for that campaign instead of creating a new campaign.\n\nINPUTS:\n- `clientProspectId` is REQUIRED for net-new campaigns. It is the EnrichedProspect row ID for the campaign sender (used by ICP scoring, message generation, brief rendering — every downstream consumer reads sender context from `campaign.clientProspect`).\n- To obtain a `clientProspectId`, run the sender-enrichment flow first: this materializes an EnrichedProspect row from the sender\'s LinkedIn profile URL and returns its ID.\n- `senderLinkedinUrl` may be passed as informational metadata but is NOT a substitute for `clientProspectId` the API will reject the create if `clientProspectId` is missing.\n\nPREREQUISITES:\n- The /research-sender skill must have been run for this sender (produces enrichment data and the EnrichedProspect row whose ID is `clientProspectId`).',
127
127
  inputSchema: {
128
128
  type: "object",
129
129
  properties: {
@@ -137,13 +137,13 @@ export const campaignToolDefinitions = [
137
137
  },
138
138
  clientProspectId: {
139
139
  type: "string",
140
- description: "Optional. Existing EnrichedProspect row ID. Pass this OR senderLinkedinUrl. If both supplied, this wins.",
140
+ description: "REQUIRED for net-new campaigns. EnrichedProspect row ID for the campaign sender. Produced by the sender-enrichment flow (research-sender). The API will reject the create with a 400 if this is missing.",
141
141
  },
142
- // Phase 114: named `senderLinkedinUrl` (not `linkedinUrl`) to disambiguate from
142
+ // `senderLinkedinUrl` (not `linkedinUrl`) to disambiguate from
143
143
  // prospect-side `linkedinUrl` fields used in other tools (research-prospect etc.).
144
144
  senderLinkedinUrl: {
145
145
  type: "string",
146
- description: "Optional. Sender's LinkedIn profile URL (e.g. https://www.linkedin.com/in/jane-doe/). Preferred for net-new campaigns. Mutually compatible with clientProspectId (the latter wins if both supplied).",
146
+ description: "Optional informational metadata: the sender's LinkedIn profile URL. NOT a substitute for clientProspectId to materialize a prospect from a URL, run the sender-enrichment flow first.",
147
147
  },
148
148
  offerPositioning: {
149
149
  type: "object",
@@ -597,12 +597,15 @@ export async function createCampaign(input) {
597
597
  missing.push("name");
598
598
  if (!input.campaignBrief)
599
599
  missing.push("campaignBrief");
600
- if (!input.clientProspectId && !input.senderLinkedinUrl) {
601
- missing.push("clientProspectId-or-senderLinkedinUrl");
600
+ // clientProspectId is required for net-new campaigns. Every downstream
601
+ // consumer (ICP scoring, message generation, brief rendering) reads sender
602
+ // context from `campaign.clientProspect`. senderLinkedinUrl is informational
603
+ // metadata only — it is NOT a substitute and the API will reject the create
604
+ // if clientProspectId is missing.
605
+ if (!input.clientProspectId) {
606
+ missing.push("clientProspectId");
602
607
  }
603
- // Phase 114 edge-case fix: cheap URL sanity check on senderLinkedinUrl.
604
- // Don't deep-validate; just reject obvious garbage so the downstream
605
- // flow doesn't try to fetch a non-LinkedIn URL.
608
+ // Cheap URL sanity check on senderLinkedinUrl when supplied.
606
609
  if (input.senderLinkedinUrl &&
607
610
  typeof input.senderLinkedinUrl === "string" &&
608
611
  !input.senderLinkedinUrl.includes("linkedin.com")) {
@@ -614,7 +617,7 @@ export async function createCampaign(input) {
614
617
  `or all create fields. Missing: ${missing.join(", ")}.\n\n` +
615
618
  "Remediation:\n" +
616
619
  '- For full workflow, call get_subskill_prompt({ subskillName: "create-campaign" }) and follow it.\n' +
617
- '- For net-new campaign creation, call get_subskill_prompt({ subskillName: "research-sender" }), run sender research (parallel batch no enrich_sender required), call complete_sender_research(...), then call create_campaign with EITHER clientProspectId OR senderLinkedinUrl (the sender\'s LinkedIn profile URL).\n' +
620
+ "- For net-new campaign creation: run the `research-sender` flow first to materialize an EnrichedProspect for the sender from their LinkedIn profile URL, then pass the resulting prospect ID as `clientProspectId`. The campaign sender context (used by ICP scoring, message generation, brief rendering) is read from `campaign.clientProspect`, so this row must exist before the campaign is minted.\n" +
618
621
  "- For resume, call create_campaign with campaignId only.");
619
622
  }
620
623
  assertNetNewCreateCampaignResearchReady();
@@ -38,6 +38,12 @@ type WaitForCliLoginSuccess = {
38
38
  activeWorkspaceId: string;
39
39
  activeWorkspaceName?: string;
40
40
  configPath: string;
41
+ /**
42
+ * True if the server reused the user's existing default workspace
43
+ * (vs creating a new one). The skill uses this to branch the
44
+ * post-auth greeting copy.
45
+ */
46
+ isReturningUser: boolean;
41
47
  /** Locked verbatim string the agent shows the user. */
42
48
  humanMessage: string;
43
49
  };
@@ -131,7 +131,7 @@ export async function handleStartCliLogin(args) {
131
131
  sessionId: body.sessionId,
132
132
  confirmUrl: body.confirmUrl,
133
133
  expiresInSec: body.expiresInSec,
134
- humanMessage: `Magic link sent to ${args.email}. Click it from your inbox I'll wait. (If your team already has a Sellable workspace, ask an admin to invite you instead — that gets you straight into their data.)`,
134
+ humanMessage: `Magic link sent to ${args.email}.\n\n─────────────────────────────────────────────\n Your turn check your inbox\n─────────────────────────────────────────────\n\n 1. Open the email from Sellable\n 2. Click the magic link\n 3. Come back here when you're done\n\nI'll be waiting right here.\n\n (If your team already uses Sellable, ask an admin to invite you into their shared workspace instead — that gets you straight in.)`,
135
135
  };
136
136
  }
137
137
  export async function handleWaitForCliLogin(args) {
@@ -187,6 +187,7 @@ export async function handleWaitForCliLogin(args) {
187
187
  ? { activeWorkspaceName: body.activeWorkspaceName }
188
188
  : {}),
189
189
  configPath,
190
+ isReturningUser: body.isReturningUser ?? false,
190
191
  humanMessage: "Signed in. Continuing.",
191
192
  };
192
193
  }
@@ -15,6 +15,7 @@ export declare const linkedinToolDefinitions: ({
15
15
  };
16
16
  postUrl?: undefined;
17
17
  sources?: undefined;
18
+ full?: undefined;
18
19
  companyUrl?: undefined;
19
20
  sortBy?: undefined;
20
21
  linkedin_url?: undefined;
@@ -44,6 +45,7 @@ export declare const linkedinToolDefinitions: ({
44
45
  default: number;
45
46
  };
46
47
  linkedinUrl?: undefined;
48
+ full?: undefined;
47
49
  companyUrl?: undefined;
48
50
  sortBy?: undefined;
49
51
  linkedin_url?: undefined;
@@ -61,6 +63,11 @@ export declare const linkedinToolDefinitions: ({
61
63
  type: string;
62
64
  description: string;
63
65
  };
66
+ full: {
67
+ type: string;
68
+ description: string;
69
+ default: boolean;
70
+ };
64
71
  limit?: undefined;
65
72
  postUrl?: undefined;
66
73
  sources?: undefined;
@@ -85,6 +92,7 @@ export declare const linkedinToolDefinitions: ({
85
92
  limit?: undefined;
86
93
  postUrl?: undefined;
87
94
  sources?: undefined;
95
+ full?: undefined;
88
96
  sortBy?: undefined;
89
97
  linkedin_url?: undefined;
90
98
  max_posts?: undefined;
@@ -115,6 +123,7 @@ export declare const linkedinToolDefinitions: ({
115
123
  linkedinUrl?: undefined;
116
124
  postUrl?: undefined;
117
125
  sources?: undefined;
126
+ full?: undefined;
118
127
  linkedin_url?: undefined;
119
128
  max_posts?: undefined;
120
129
  };
@@ -139,6 +148,7 @@ export declare const linkedinToolDefinitions: ({
139
148
  limit?: undefined;
140
149
  postUrl?: undefined;
141
150
  sources?: undefined;
151
+ full?: undefined;
142
152
  companyUrl?: undefined;
143
153
  sortBy?: undefined;
144
154
  };
@@ -158,6 +168,7 @@ export declare const linkedinToolDefinitions: ({
158
168
  limit?: undefined;
159
169
  postUrl?: undefined;
160
170
  sources?: undefined;
171
+ full?: undefined;
161
172
  companyUrl?: undefined;
162
173
  sortBy?: undefined;
163
174
  max_posts?: undefined;
@@ -177,7 +188,9 @@ export declare function fetchLinkedInPosts(linkedinUrl: string, limit?: number):
177
188
  posts: SerializedPost[];
178
189
  count: number;
179
190
  }>;
180
- export declare function fetchLinkedInProfile(linkedinUrl: string): Promise<unknown>;
191
+ export declare function fetchLinkedInProfile(linkedinUrl: string, options?: {
192
+ full?: boolean;
193
+ }): Promise<unknown>;
181
194
  export declare function fetchCompany(companyUrl: string): Promise<unknown>;
182
195
  export declare function fetchCompanyPosts(companyUrl: string, limit?: number, sortBy?: "recent" | "top"): Promise<{
183
196
  posts: SerializedPost[];
@@ -46,7 +46,7 @@ export const linkedinToolDefinitions = [
46
46
  },
47
47
  {
48
48
  name: "fetch_linkedin_profile",
49
- description: "Fetch LinkedIn profile details from Sellable scrape endpoints.",
49
+ description: "Fetch LinkedIn profile details. Defaults to the cheaper 'main' payload — same envelope shape with most-recent role, headline, about, location, education, top skills, certifications, languages, projects, etc. Pass full=true ONLY when you need the long-tail experience history (positions 6+) or complete skill list (skills 3+) for deep research. Most personalization, qualification, and outreach use cases work with main; reach for full only when the user explicitly asks for full work history or comprehensive skill audit.",
50
50
  inputSchema: {
51
51
  type: "object",
52
52
  properties: {
@@ -54,6 +54,11 @@ export const linkedinToolDefinitions = [
54
54
  type: "string",
55
55
  description: "Full LinkedIn profile URL",
56
56
  },
57
+ full: {
58
+ type: "boolean",
59
+ description: "Opt into the full payload (~38% more expensive, slightly slower). Set true only when you specifically need the long-tail experience history or full skill list. Default false (main payload).",
60
+ default: false,
61
+ },
57
62
  },
58
63
  required: ["linkedinUrl"],
59
64
  },
@@ -152,9 +157,12 @@ export async function fetchLinkedInPosts(linkedinUrl, limit = 25) {
152
157
  count: posts.length,
153
158
  };
154
159
  }
155
- export async function fetchLinkedInProfile(linkedinUrl) {
160
+ export async function fetchLinkedInProfile(linkedinUrl, options = {}) {
156
161
  const api = getApi();
157
162
  const params = new URLSearchParams({ linkedinUrl });
163
+ if (options.full) {
164
+ params.set("full", "true");
165
+ }
158
166
  return api.get(`/api/v1/scrape/linkedin/profile?${params.toString()}`);
159
167
  }
160
168
  export async function fetchCompany(companyUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -331,7 +331,15 @@ updates.
331
331
  a. Say to the user verbatim:
332
332
 
333
333
  ```text
334
- Welcome to Sellable. What's your email?
334
+ Welcome to Sellable! I'll help you launch a LinkedIn outbound campaign right here, all via chat — leads, messages, the whole thing.
335
+
336
+ First, let's connect your Sellable account:
337
+
338
+ 1. Drop your email below
339
+ 2. I'll send a magic login link to your inbox
340
+ 3. Click it, come back here, and we'll keep going
341
+
342
+ What email should I use?
335
343
  ```
336
344
 
337
345
  b. Wait for the user to paste their email in normal chat. Do NOT use
@@ -347,7 +355,19 @@ updates.
347
355
  as the user typed it):
348
356
 
349
357
  ```text
350
- Magic link sent to {email}. Click it from your inbox — I'll wait. (If your team already has a Sellable workspace, ask an admin to invite you instead — that gets you straight into their data.)
358
+ Magic link sent to {email}.
359
+
360
+ ─────────────────────────────────────────────
361
+ Your turn — check your inbox
362
+ ─────────────────────────────────────────────
363
+
364
+ 1. Open the email from Sellable
365
+ 2. Click the magic link
366
+ 3. Come back here when you're done
367
+
368
+ I'll be waiting right here.
369
+
370
+ (If your team already uses Sellable, ask an admin to invite you into their shared workspace instead — that gets you straight in.)
351
371
  ```
352
372
 
353
373
  f. Call `mcp__sellable__wait_for_cli_login({ sessionId })` using the
@@ -370,13 +390,33 @@ updates.
370
390
  `error.guidance` and stop.
371
391
 
372
392
  - On `ok: true`, the user is signed in and `~/.sellable/config.json` has
373
- been written. Your IMMEDIATE next visible message MUST be the locked
374
- Step 3 narration verbatim (no welcome line, no "all set", no "you're
375
- signed in", no acknowledgement of any kind):
393
+ been written. Your IMMEDIATE next visible message branches on
394
+ `isReturningUser` from the tool result:
376
395
 
377
- ```text
378
- Now paste the LinkedIn profile URL of the person you want to send from.
379
- ```
396
+ - If `isReturningUser === true`, prepend ONE line acknowledging the
397
+ reused workspace, then the locked Step 3 narration verbatim
398
+ (substituting `activeWorkspaceName` exactly):
399
+
400
+ ```text
401
+ You're in — {activeWorkspaceName} workspace, ready to roll.
402
+
403
+ Now — paste the LinkedIn profile URL of the person you'll be sending campaigns from. Usually that's you (the founder), or whoever's voice the messages should sound like.
404
+
405
+ e.g. https://www.linkedin.com/in/your-handle
406
+ ```
407
+
408
+ - If `isReturningUser === false`, prepend ONE line confirming the new
409
+ workspace, then the locked Step 3 narration verbatim:
410
+
411
+ ```text
412
+ You're set up — your {activeWorkspaceName} workspace is ready.
413
+
414
+ Now — paste the LinkedIn profile URL of the person you'll be sending campaigns from. Usually that's you (the founder), or whoever's voice the messages should sound like.
415
+
416
+ e.g. https://www.linkedin.com/in/your-handle
417
+ ```
418
+
419
+ No other lines. No "all set", no "signed in", no other acknowledgement.
380
420
 
381
421
  After the user pastes the URL, proceed with the existing identity-first
382
422
  sender flow (Step 3 onwards in the v2 subskill prompt — sender
@@ -0,0 +1,266 @@
1
+ # AI-Native Tokens
2
+
3
+ The canonical spec for personalization tokens in Sellable campaign messages.
4
+
5
+ ## Why this exists
6
+
7
+ Old-school personalization tokens (`{{first_name}}`, `{{company}}`,
8
+ `{{workflow_context}}`) are field substitutions: a string is looked up and
9
+ spliced into a fixed sentence. That works for atomic facts (first name,
10
+ company name). It fails for anything richer — because the _grammatical shape_
11
+ of the surrounding sentence locks in what the substituted value must look
12
+ like, and the rules about what makes a good substitution live in a separate
13
+ "Token Fill Rules" table that the generation model can easily ignore or
14
+ forget.
15
+
16
+ Concrete failure mode this caused in production: the template
17
+ `Thought of {{company}} because of your {{workflow_context}}.` was filled
18
+ with `Moneyball dashboard for CEOs` for a prospect who _built_ that product
19
+ at his company. The rendered line —
20
+ `Thought of Hatchproof because of your Moneyball dashboard for CEOs.` —
21
+ read as if we mistook the founder for a customer of his own product.
22
+ Field-substitution tokens have no built-in defense against that.
23
+
24
+ **AI-native tokens fix this.** The token _names what job the line does_ and
25
+ carries its rules _inline_. The model writes a SENTENCE (not fills a slot)
26
+ that satisfies the inline contract, or omits the line entirely. The grammar
27
+ trap disappears because the model is composing the line from scratch instead
28
+ of plugging a noun into a fixed frame.
29
+
30
+ ## The contract
31
+
32
+ An AI-native token is a bracketed instruction in the message template:
33
+
34
+ ```text
35
+ [ALL_CAPS_NAME — instructions describing what the line should do, with
36
+ DO / DON'T rules and a FALLBACK clause]
37
+ ```
38
+
39
+ Three properties define it:
40
+
41
+ 1. **Named by intent, not by field.** `[PERSONALIZATION_LINE — ...]` says
42
+ what JOB the line does. `{{workflow_context}}` only names a slot. When
43
+ the model sees an intent name, it has a target to write toward.
44
+ 2. **Rules live inline.** DO / DON'T / FALLBACK appear inside the bracket,
45
+ adjacent to where the line gets written. The model doesn't have to
46
+ consult a faraway rules table and remember to apply it.
47
+ 3. **Sentence-shaped, not noun-shaped.** The model produces an entire
48
+ sentence (or no sentence). There is no fixed frame around the token, so
49
+ no grammar trap. A weak fill is still a complete sentence; a wrong fill
50
+ is detectable at the sentence level.
51
+
52
+ The bracket is replaced at generation time by either (a) the rendered
53
+ sentence, or (b) nothing (omit). The brackets themselves never appear in
54
+ the final message.
55
+
56
+ ## Required clauses inside an AI-native token
57
+
58
+ Every well-specified AI-native token must include four things:
59
+
60
+ - **Intent.** One sentence on what job this line does in the message. Why
61
+ the line exists. What it should accomplish for the buyer.
62
+ - **DO.** 2-4 specific shapes the model should produce. Verb-led
63
+ prescriptions. Examples that work.
64
+ - **DON'T.** 2-4 anti-patterns the model must reject. Failure modes
65
+ observed in real campaigns. Bad shapes paired with WHY they're bad
66
+ when the bad-ness isn't obvious from the shape alone.
67
+ - **FALLBACK.** What to do when none of the DO shapes can be satisfied
68
+ cleanly from the row data. The default fallback is OMIT THE ENTIRE LINE.
69
+ Aggressive omit beats awkward fill, every time. Never silently
70
+ substitute a generic noun.
71
+
72
+ A token without all four clauses is under-specified. The model will
73
+ hallucinate the missing rules from priors, and those priors include
74
+ generic mail-merge failure modes.
75
+
76
+ ## The canonical example
77
+
78
+ This is the original Sellable gold-standard message. It uses two AI-native
79
+ tokens. Both have the contract baked in (loosely — even an under-specified
80
+ intent-name dramatically beats a field token, but the formal contract makes
81
+ it bulletproof):
82
+
83
+ ```text
84
+ hey [name],
85
+
86
+ saw you raise your hand for claude + gtm (creepy to reach out based on
87
+ that, i know) - but this felt too on the nose to ignore.
88
+
89
+ i'm building sellable, the only gtm platform that runs natively on claude
90
+ code.
91
+
92
+ we're looking for design partners - and [PERSONALIZED REASON - their team
93
+ size, role, or why they're a perfect fit].
94
+
95
+ two options:
96
+
97
+ a) 15-min call - i'll show you how you could book more meetings with
98
+ [THEIR ICP - who they want to reach], and if you like it we launch a pilot
99
+ right there
100
+
101
+ b) i send you the video of me using sellable to write and send this exact
102
+ message to you (yes, it's that meta)
103
+
104
+ p.s. yes, this message was entirely written and sent via claude code 😊
105
+ ```
106
+
107
+ Note what the tokens DO:
108
+
109
+ - `[PERSONALIZED REASON - their team size, role, or why they're a perfect fit]`
110
+ — the brackets name an intent (PERSONALIZED REASON) and offer three
111
+ acceptable input sources (team size, role, fit reason). The model picks
112
+ whichever is supported by the row.
113
+ - `[THEIR ICP - who they want to reach]` — names what the slot is FOR
114
+ ("their ICP") and clarifies in plain English ("who they want to reach"),
115
+ so the model doesn't need a glossary.
116
+
117
+ Both feel hand-crafted in every rendered version because the model is
118
+ _writing_, not _filling_.
119
+
120
+ ## The fully-specified contract (use this for new campaigns)
121
+
122
+ When authoring a new brief, write tokens in the full contract form. The
123
+ canonical Sellable example is loose because it predates the contract; it
124
+ still works because the intent names are crystal clear, but new tokens
125
+ should err toward explicit:
126
+
127
+ ```text
128
+ [PERSONALIZATION_LINE — write ONE short sentence (8-15 words) that anchors
129
+ this message to {{first_name}} as a person. Name what they personally DO,
130
+ write about, focus on, or build TOWARD (not what their company SELLS).
131
+ DO: use verb-led shapes — "Saw your work helping X", "Your writing on Y
132
+ caught my eye", "Your focus on Z is rare in this segment".
133
+ DON'T: name a product their company sells ("your dashboard for X", "your
134
+ platform for Y") — they BUILD the product, they don't have one.
135
+ DON'T: use source-citation phrasing ("Saw your post about", "Your bio
136
+ says").
137
+ DON'T: use generic noun substitutes ("your work", "your stack", "your
138
+ team") — those add no relevance.
139
+ FALLBACK: if you can't satisfy the DOs cleanly from the row data, OMIT
140
+ this entire line. The message reads cleanly without it. Aggressive omit >
141
+ awkward fill.]
142
+ ```
143
+
144
+ That bracket is ~15 lines. It produces ONE sentence in the rendered
145
+ message (or zero). The verbosity inside the bracket is the price of
146
+ predictable output across thousands of rows.
147
+
148
+ ## The grammar test
149
+
150
+ Every personalization line should pass this test before rendering: read
151
+ `your <X>` (or whatever possessive frame the line uses) out loud. If "X"
152
+ reads as if the recipient _uses_ or _has_ the thing, but the recipient
153
+ actually _builds_ or _sells_ the thing, the fill is wrong → OMIT.
154
+
155
+ Examples:
156
+
157
+ | Filled value | Pass test? |
158
+ | ----------------------------------------- | ------------------------------------ |
159
+ | `your monetization work for AI founders` | ✅ recipient does this work |
160
+ | `your founder voice on team performance` | ✅ recipient has this voice |
161
+ | `your Moneyball dashboard for CEOs` | ❌ recipient _builds_ this dashboard |
162
+ | `your monetization layer for AI builders` | ❌ recipient _sells_ this product |
163
+ | `your AI scoring engine` | ❌ product they sell |
164
+ | `your work` | ❌ generic, adds no relevance |
165
+ | `your team` | ❌ generic, adds no relevance |
166
+
167
+ When in doubt, omit. The message must read cleanly without the
168
+ personalization line. If it doesn't, the rest of the message is
169
+ under-specified — fix that, don't paper over it with weak personalization.
170
+
171
+ ## Field-substitution tokens still have a role
172
+
173
+ AI-native tokens replace personalization sentences. They do NOT replace
174
+ atomic field substitutions like `{{first_name}}` and `{{company}}`. Use
175
+ field tokens when:
176
+
177
+ - The value is an atomic noun that goes in a fixed slot (greeting:
178
+ `Hey {{first_name}},`, subject: `{{company}} outbound + ...`)
179
+ - The value comes directly from a row column with no judgment required
180
+ - Failure mode is "missing field" not "wrong shape" — and the fallback is
181
+ trivial (`there` instead of first name, omit the soft-bridge instead of
182
+ company)
183
+
184
+ Use AI-native tokens when:
185
+
186
+ - The value is a SENTENCE that requires judgment about what to write
187
+ - The value depends on synthesizing multiple row fields (bio + recent
188
+ posts + company context) into a human-sounding line
189
+ - Failure mode is "wrong shape" or "awkward grammar" — needs rules to
190
+ prevent
191
+ - The line is optional (you'd rather have NO line than a bad line)
192
+
193
+ Most campaigns will use both: field tokens for greeting/subject/company,
194
+ AI-native tokens for any personalization or context-bridging lines.
195
+
196
+ ## Brief authoring guide
197
+
198
+ When writing a campaign brief, document each token in two places:
199
+
200
+ ### 1. In the message template itself
201
+
202
+ Inline AI-native tokens go directly in the template body:
203
+
204
+ ```text
205
+ Hey {{first_name}},
206
+
207
+ [FIRST_LINE — opener that anchors to {{company}}'s current situation.
208
+ DO: ... DON'T: ... FALLBACK: ...]
209
+
210
+ [BODY — ... ]
211
+ ```
212
+
213
+ ### 2. In the Token Fill Rules section
214
+
215
+ A small table cataloging every token, marked by type:
216
+
217
+ ```markdown
218
+ | Token | Type | Source / Instructions | Fallback |
219
+ | ------------------------------ | --------- | --------------------------------------------- | ----------------------- |
220
+ | `{{first_name}}` | Field | Sales Nav `firstName` | `there` |
221
+ | `{{company}}` | Field | Sales Nav `organization.name`; strip suffixes | omit dependent sentence |
222
+ | `[PERSONALIZATION_LINE — ...]` | AI-native | Inline. See bracket. | Omit the line. |
223
+ ```
224
+
225
+ The AI-native row in the table doesn't need to repeat the instructions —
226
+ they live inline. The table just confirms the token exists, what type it
227
+ is, and where the fallback resolves.
228
+
229
+ ## Common authoring mistakes
230
+
231
+ - **Naming the token by source field instead of intent.**
232
+ `[LINKEDIN_BIO_PHRASE — ...]` is bad. `[PERSONALIZATION_LINE — ...]` is
233
+ good. The intent name tells the model what job to do; the source field
234
+ tells the model where to look but not what good looks like.
235
+ - **Rules in a separate table, intent in the template.** Keep them
236
+ together. The model's attention budget is finite — separating intent
237
+ from rules invites the model to forget the rules.
238
+ - **No FALLBACK clause.** Without it, the model assumes a fill is always
239
+ required and substitutes a generic noun ("your work") rather than
240
+ omitting. Always include FALLBACK.
241
+ - **Vague DO list.** `DO: be specific and human` is not a DO. Spell out
242
+ the verb-led shapes: `DO: use "your work helping X", "your writing on
243
+ Y", "Saw you're pushing into Z"`. The model needs prescriptions, not
244
+ vibes.
245
+ - **DON'T list that only describes obvious failures.** The DON'T clause
246
+ is most valuable when it names the non-obvious failures: product-noun
247
+ substitution, source-citation phrasing, mind-reading from signals,
248
+ generic noun substitutes.
249
+ - **Bracket containing freeform prose.** Keep the bracket's clauses
250
+ labeled (DO / DON'T / FALLBACK). Prose paragraphs are harder for the
251
+ model to scan and easier to ignore.
252
+
253
+ ## Cross-references
254
+
255
+ - `mcp/sellable/skills/generate-messages/SKILL.md` — the skill that
256
+ invokes this token system at message-generation time. Carries the
257
+ hard-invariant rules (no product-noun substitution, omit-fallback as
258
+ default, etc.).
259
+ - `mcp/sellable/skills/create-campaign/references/token-fill-examples.md`
260
+ — example archive showing good/bad fills across many shapes.
261
+ - `mcp/sellable/skills/create-campaign-brief/references/brief-template.md`
262
+ — the brief template that authors should populate with AI-native tokens
263
+ by default.
264
+ - `mcp/sellable/skills/create-campaign-brief/references/phase75-active-runtime-message-pack.md`
265
+ — the runtime gold examples, including the original Sellable message
266
+ with `[PERSONALIZED REASON — ...]` and `[THEIR ICP — ...]`.
@@ -2,6 +2,16 @@
2
2
 
3
3
  Use this file when template-style personalization is in play.
4
4
 
5
+ > **Read `ai-native-tokens.md` first.** The Sellable canonical token style is
6
+ > AI-native: bracketed `[ALL_CAPS_NAME — instructions]` placeholders that
7
+ > name what JOB the line does and carry DO / DON'T / FALLBACK rules inline.
8
+ > The model writes a sentence (or omits) per the inline contract — it does
9
+ > NOT fill a fixed slot. That spec is the source of truth; this file shows
10
+ > good/bad examples across shapes. Old-style `{{field}}` substitutions still
11
+ > apply for atomic values (first name, company name) but are insufficient for
12
+ > personalization sentences — see the Product-Noun Substitution section
13
+ > below for the failure mode they enable.
14
+
5
15
  ## Good Token Fill
6
16
 
7
17
  Good token fill feels like a human wrote the sentence after seeing the
@@ -79,3 +89,86 @@ Revvix / security positioning:
79
89
  - the token makes the sentence longer without making it better
80
90
  - the token sounds like a compliment sandwich
81
91
  - the token could be swapped into any message without changing meaning
92
+
93
+ ## Product-Noun Substitution (HARD INVARIANT — block before fill)
94
+
95
+ When a token sits inside a possessive frame like `your {{X}}`, `at your {{X}}`,
96
+ or `because of your {{X}}`, the filled value must describe something the
97
+ recipient personally **does** — their work, focus, or activity. It must NOT
98
+ describe a product their company **builds or sells**.
99
+
100
+ The grammar test: read `your <filled-value>` out loud. If it reads as if the
101
+ recipient _uses_ or _has_ the thing, but the recipient actually _builds/sells_
102
+ the thing, the fill is wrong → OMIT the entire sentence.
103
+
104
+ **Allowed (buyer activities):**
105
+
106
+ - `monetization research for indie devs`
107
+ - `founder-led-sales experiments`
108
+ - `GTM Engineering writing`
109
+ - `outbound work for AI founders`
110
+ - `pricing-strategy advisory`
111
+ - `B2B SaaS GTM background` (history is fine — it describes the person)
112
+
113
+ **Blocked (product nouns from the prospect's company):**
114
+
115
+ - `Moneyball dashboard for CEOs` ← the prospect BUILDS this; they do not have one of their own
116
+ - `monetization layer for AI builders` ← product description, not buyer activity
117
+ - `AI scoring engine` / `platform for X` / `tool for X` / `API for X` ← any company-output noun
118
+ - `Series A funding round` ← event, not activity
119
+
120
+ Why this matters: the personalization line is supposed to make the buyer feel
121
+ recognized as a person. A product-noun fill instead makes the message sound
122
+ like it was written by an enrichment scraper — and worse, it implies we
123
+ mistook the founder for a customer of their own product.
124
+
125
+ **The omit-fallback is the safe default.** When you cannot produce a
126
+ buyer-activity phrase that survives the grammar test, OMIT the sentence
127
+ entirely. The message must read cleanly without the soft-bridge line. Do NOT
128
+ substitute a generic noun (`your work`, `your stack`, `your team`) — those
129
+ add no relevance and sound mail-merge-y. Aggressive omit > awkward fill.
130
+
131
+ When the brief defines a `{{workflow_context}}` token (or any
132
+ buyer-activity-shaped token), include this rule verbatim in the brief's Token
133
+ Fill Rules section so per-row generation has the constraint in scope.
134
+
135
+ ## AI-Native Tokens (the canonical Sellable pattern)
136
+
137
+ Personalization sentences should be authored as AI-native tokens — bracketed
138
+ instructions, not field substitutions. Field substitutions (`{{first_name}}`,
139
+ `{{company}}`) work for atomic values that drop into a fixed slot. But any
140
+ sentence that requires _judgment_ about what to write should be authored as:
141
+
142
+ ```text
143
+ [ALL_CAPS_NAME — Intent. DO: ... DON'T: ... FALLBACK: omit the line.]
144
+ ```
145
+
146
+ The bracket lives inline in the message template. The model writes a sentence
147
+ (or omits the entire line) following the inline contract. The bracket itself
148
+ is replaced by the rendered sentence — or by nothing.
149
+
150
+ **Why:** every personalization failure documented in this file (mail-merge
151
+ phrasing, product-noun substitution, source-citation, generic noun
152
+ substitutes) shares one root cause: the model was filling a fixed-frame slot
153
+ under rules that lived elsewhere. AI-native tokens fix that by making the
154
+ model COMPOSE a sentence under rules that live INLINE.
155
+
156
+ **The original Sellable gold message (the canonical example)** uses two
157
+ AI-native tokens:
158
+
159
+ ```text
160
+ we're looking for design partners — and [PERSONALIZED REASON — their team
161
+ size, role, or why they're a perfect fit].
162
+
163
+ a) 15-min call — i'll show you how you could book more meetings with
164
+ [THEIR ICP — who they want to reach], and if you like it we launch a pilot
165
+ right there
166
+ ```
167
+
168
+ Notice: `[PERSONALIZED REASON]` and `[THEIR ICP]` name what JOB the line
169
+ does. The model produces a different rendered version per row, but every
170
+ version is grammatically clean because the model is _writing_, not
171
+ _filling_.
172
+
173
+ **Full spec, contract requirements, and brief-authoring guide:** see
174
+ `ai-native-tokens.md` in this same references directory.
@@ -162,6 +162,17 @@ next step, not a section the customer needs to study in detail.
162
162
  Use 0-3 bullets max. If there is nothing special to note yet, say:
163
163
  `None yet — validate from the first lead sample.`
164
164
 
165
+ **Personalization tokens — default to AI-native, not field substitution.**
166
+ Atomic field tokens (`{{first_name}}`, `{{company}}`) are fine for greeting,
167
+ subject, and any sentence that drops a single value into a fixed slot. Any
168
+ personalization SENTENCE (a hook line that anchors the message to the
169
+ prospect) should be authored as an AI-native bracket token in the message
170
+ template — `[INTENT_NAME — Intent. DO: ... DON'T: ... FALLBACK: omit the
171
+ line.]` — not as a `{{field}}` slot inside a fixed frame. The model writes
172
+ the sentence per the inline contract; if it can't satisfy the DOs cleanly,
173
+ it omits the entire line. Aggressive omit > awkward fill. Full spec and
174
+ examples: `mcp/sellable/skills/create-campaign/references/ai-native-tokens.md`.
175
+
165
176
  ## Next Steps
166
177
 
167
178
  Keep this section simple and action-oriented. The customer should understand the
@@ -1064,11 +1064,17 @@ proof:`, `p.s. useful proof:`, `p.s. proof:`, or `p.s. social proof:`.
1064
1064
  `{{psLine}}`, and do not use `{{recent_signal_quote}}`; use fields that exist
1065
1065
  on the enriched prospect row instead.
1066
1066
  - the live body under `## Approved Message Template` must be sender-ready copy.
1067
- Do not include bracketed instruction placeholders such as `[ROW BRIDGE ...]`,
1068
- `[insert ...]`, `[generated ...]`, or prose that tells a later step to
1069
- paraphrase/fill a line. Put per-row generation rules in `## Token Fill Rules`
1070
- with concrete enriched-row fields, and copy approved good/bad examples into
1071
- `## Token Fill Examples`, or route to `revise-messaging`.
1067
+ Bracketed text in the body is allowed only when it is a fully-specified
1068
+ AI-native token (`[ALL_CAPS_NAME Intent. DO: ... DON'T: ... FALLBACK:
1069
+ omit the line.]` see
1070
+ `mcp/sellable/skills/create-campaign/references/ai-native-tokens.md` for the
1071
+ contract). Bracketed instruction placeholders that lack a contract are
1072
+ BLOCKED — including `[ROW BRIDGE ...]`, `[insert ...]`, `[generated ...]`,
1073
+ and any prose that defers per-row composition to a later step without
1074
+ giving the model the rules. Either upgrade the placeholder to a full
1075
+ AI-native token (Intent / DO / DON'T / FALLBACK), put the per-row rules in
1076
+ `## Token Fill Rules` with concrete enriched-row fields, or route to
1077
+ `revise-messaging`.
1072
1078
  - `approval-packet.md` and the live campaign brief passed to `create_campaign`
1073
1079
  must include `## Approved Message Template`, `## Token Fill Rules`, and
1074
1080
  `## Token Fill Examples`. `## Token Fill Examples` must copy the approved
@@ -1391,25 +1397,39 @@ Exact sequence:
1391
1397
  `campaignBrief.content` contains `{{...}}`; without this marker it will
1392
1398
  use full-generation mode and may rewrite the approved message.
1393
1399
  The live template body must contain only sender-ready copy plus supported
1394
- `{{tokens}}`; no bracketed instructions or placeholder prose may appear in
1395
- the body.
1396
- Include `## Token Fill Rules` for every enriched-prospect-row token in the
1397
- template. Tokens may be row fields such as `{{first_name}}`,
1398
- `{{company}}`, `{{role}}`, `{{headline}}`, `{{profile_summary}}`,
1399
- `{{post_context}}`, `{{comment_summary}}`, `{{source_post_topic}}`, or
1400
- `{{row_proof_note}}` when those exact fields are supported by the
1401
- row/enrichment data. Do not document static sender identity, product name,
1402
- proof points, casing style, `{{recent_signal_quote}}`, or abstract slot
1403
- tokens such as `{{hookLine}}`, `{{painLine}}`, `{{productLine}}`,
1404
- `{{closeLine}}`, or `{{psLine}}`.
1405
- Include `## Token Fill Examples` copied from the approved message review and
1406
- validation artifacts. It must preserve `Good token fill:`, `Good omit:`,
1407
- `Bad token fill:`, `Why bad:`, `Fallback if missing:`, and
1408
- `Token fill basis:` so the minted campaign brief teaches future row
1409
- generation how to fill tokens, what fills are blocked, and what to do when
1410
- row data is missing. If the brief does not contain `## Token Fill Rules` and
1411
- `## Token Fill Examples`, do not call `create_campaign`; route back to
1412
- message review or approval packet generation.
1400
+ tokens. Two token shapes are allowed:
1401
+ - `{{snake_case}}` field-substitution tokens for atomic values that drop
1402
+ directly into a fixed slot (greeting, subject, company name).
1403
+ - `[ALL_CAPS_NAME — Intent. DO: ... DON'T: ... FALLBACK: omit the line.]`
1404
+ AI-native tokens for personalization sentences. The bracket carries
1405
+ the contract inline; the model writes a sentence (or omits the line)
1406
+ per the inline rules. Full spec:
1407
+ `mcp/sellable/skills/create-campaign/references/ai-native-tokens.md`.
1408
+ No other bracketed placeholders are allowed bracketed prose without an
1409
+ explicit Intent / DO / DON'T / FALLBACK contract (e.g., `[ROW BRIDGE]`,
1410
+ `[insert ...]`, `[generated ...]`) is BLOCKED because it leaves the
1411
+ per-row generation under-specified.
1412
+ Include `## Token Fill Rules` for every token in the template. Field
1413
+ tokens may be row fields such as `{{first_name}}`, `{{company}}`,
1414
+ `{{role}}`, `{{headline}}`, `{{profile_summary}}`, `{{post_context}}`,
1415
+ `{{comment_summary}}`, `{{source_post_topic}}`, or `{{row_proof_note}}`
1416
+ when those exact fields are supported by the row/enrichment data. AI-native
1417
+ tokens are listed in the rules table by name and type with a pointer back
1418
+ to the inline contract (the rules themselves live inside the bracket — do
1419
+ not duplicate them in the table). Do not document static sender identity,
1420
+ product name, proof points, casing style, `{{recent_signal_quote}}`, or
1421
+ abstract slot tokens such as `{{hookLine}}`, `{{painLine}}`,
1422
+ `{{productLine}}`, `{{closeLine}}`, or `{{psLine}}` — those are abstract
1423
+ slots without a contract; convert them to AI-native bracket tokens with
1424
+ real Intent / DO / DON'T / FALLBACK clauses, or cut them.
1425
+ Include `## Token Fill Examples` copied from the approved message review and
1426
+ validation artifacts. It must preserve `Good token fill:`, `Good omit:`,
1427
+ `Bad token fill:`, `Why bad:`, `Fallback if missing:`, and
1428
+ `Token fill basis:` so the minted campaign brief teaches future row
1429
+ generation how to fill tokens, what fills are blocked, and what to do when
1430
+ row data is missing. If the brief does not contain `## Token Fill Rules` and
1431
+ `## Token Fill Examples`, do not call `create_campaign`; route back to
1432
+ message review or approval packet generation.
1413
1433
  3. Call `bootstrap_create_campaign({ flowVersion: "v2" })`. Respect
1414
1434
  `safeToProceed`; on blocking errors, surface and stop.
1415
1435
  4. Call
@@ -1139,6 +1139,44 @@ yours` are not enough when a safe row token would make the line feel more
1139
1139
  version sounds like mail merge. A good token should either make the sentence
1140
1140
  more concrete in normal language or be omitted. If the row signal is weak,
1141
1141
  write the segment-level line and document the omit rule.
1142
+ - **No product-noun substitution in possessive frames (HARD INVARIANT):** when
1143
+ a token sits inside a possessive frame like `your {{X}}`, `at your {{X}}`,
1144
+ or `because of your {{X}}`, the filled value MUST describe something the
1145
+ recipient personally **does** — their work, focus, or activity. It must NOT
1146
+ describe a product their company **builds or sells**. Grammar test: read
1147
+ `your <filled-value>` out loud. If it reads as if the recipient _uses_ or
1148
+ _has_ the thing, but they actually _build/sell_ the thing, the fill is wrong
1149
+ → OMIT the entire sentence. Examples: `your monetization layer for AI
1150
+ builders` (BLOCKED — product description from the company), `your Moneyball
1151
+ dashboard for CEOs` (BLOCKED — the recipient builds this product, doesn't
1152
+ have one of their own), `your AI scoring engine` (BLOCKED — product), vs
1153
+ `your monetization research for AI founders` (ALLOWED — buyer activity),
1154
+ `your founder-led-sales experiments` (ALLOWED — activity), `your GTM
1155
+ Engineering writing` (ALLOWED — what they do publicly). The omit-fallback
1156
+ is the safe default. Aggressive omit > awkward fill. Do NOT substitute
1157
+ generic nouns (`your work`, `your stack`, `your team`) — those add no
1158
+ relevance and read as mail merge.
1159
+ - **AI-native tokens — write the sentence, don't fill the slot (HARD
1160
+ INVARIANT):** when the message template contains a bracketed instruction
1161
+ token of the form \`[ALL_CAPS_NAME — instructions]\`, that placeholder is
1162
+ NOT a field-substitution slot. It is an inline instruction. Read the
1163
+ contents of the bracket. The bracket will name an Intent, DO shapes,
1164
+ DON'T shapes, and a FALLBACK clause. Your job: COMPOSE a complete
1165
+ sentence (or sentences, if the intent calls for it) that satisfies all
1166
+ the DO rules and avoids all the DON'T rules, then replace the bracket
1167
+ with that rendered sentence. If you cannot satisfy the DOs cleanly from
1168
+ the row data, REPLACE THE BRACKET WITH NOTHING (the entire line is
1169
+ omitted, including any leading/trailing whitespace; the surrounding
1170
+ message must read cleanly without it). Aggressive omit > awkward fill.
1171
+ Never leave the bracket itself in the rendered output. Never substitute
1172
+ a placeholder, ellipsis, or generic noun (\`your work\`, \`your team\`)
1173
+ when the FALLBACK clause says omit. Canonical example from the runtime
1174
+ gold pack: \`[PERSONALIZED REASON — their team size, role, or why
1175
+ they're a perfect fit]\` — the model picks whichever input the row supports
1176
+ and writes one short sentence in the sender's voice. Full spec:
1177
+ \`references/ai-native-tokens.md\`. The \`[ALL_CAPS_NAME — ...]\` shape
1178
+ is reserved for AI-native tokens; field substitutions stay as
1179
+ \`{{snake_case}}\` and continue to work as direct value injections.
1142
1180
  - **No internal profile-signal token:** `{{profile_signal}}` is never allowed
1143
1181
  in customer-facing copy, message-review templates, rendered examples, token
1144
1182
  notes, or approval-packet message bodies. It names how enrichment classified