@sellable/mcp 0.1.318 → 0.1.319

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.
@@ -47,7 +47,7 @@ export const linkedinToolDefinitions = [
47
47
  },
48
48
  {
49
49
  name: "fetch_linkedin_profile",
50
- 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
+ description: "Fetch LinkedIn profile details. Defaults to compact context (fetchMinimal=true) that strips images/media/provider metadata and returns identity, headline, about, current role, company, limited experience, education, skills, certifications, languages, and projects. Pass fetchMinimal=false only when you need raw provider fields or media URLs. Pass full=true ONLY when you need the long-tail experience history (positions 6+) or complete skill list (skills 3+) for deep research; full still returns compact output unless fetchMinimal=false.",
51
51
  inputSchema: {
52
52
  type: "object",
53
53
  properties: {
@@ -60,6 +60,11 @@ export const linkedinToolDefinitions = [
60
60
  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).",
61
61
  default: false,
62
62
  },
63
+ fetchMinimal: {
64
+ type: "boolean",
65
+ description: "Return compact agent context by stripping images/media/provider metadata and limiting long lists. Default true. Set false only when raw provider fields or media URLs are needed.",
66
+ default: true,
67
+ },
63
68
  },
64
69
  required: ["linkedinUrl"],
65
70
  },
@@ -187,6 +192,348 @@ function collectImageUrls(value, urls = new Set()) {
187
192
  function pickString(value) {
188
193
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
189
194
  }
195
+ function isRecord(value) {
196
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
197
+ }
198
+ function pickRecord(value, keys) {
199
+ if (!isRecord(value))
200
+ return undefined;
201
+ for (const key of keys) {
202
+ const candidate = value[key];
203
+ if (isRecord(candidate))
204
+ return candidate;
205
+ }
206
+ return undefined;
207
+ }
208
+ function pickStringFrom(value, keys, maxLength = 1200) {
209
+ if (!isRecord(value))
210
+ return undefined;
211
+ for (const key of keys) {
212
+ const candidate = value[key];
213
+ const picked = typeof candidate === "string" || typeof candidate === "number"
214
+ ? compactString(candidate, maxLength)
215
+ : undefined;
216
+ if (picked)
217
+ return picked;
218
+ }
219
+ return undefined;
220
+ }
221
+ function pickNumberFrom(value, keys) {
222
+ if (!isRecord(value))
223
+ return undefined;
224
+ for (const key of keys) {
225
+ const candidate = value[key];
226
+ if (typeof candidate === "number" && Number.isFinite(candidate)) {
227
+ return candidate;
228
+ }
229
+ if (typeof candidate === "string" && candidate.trim()) {
230
+ const parsed = Number(candidate.replace(/,/g, ""));
231
+ if (Number.isFinite(parsed))
232
+ return parsed;
233
+ }
234
+ }
235
+ return undefined;
236
+ }
237
+ function pickBooleanFrom(value, keys) {
238
+ if (!isRecord(value))
239
+ return undefined;
240
+ for (const key of keys) {
241
+ const candidate = value[key];
242
+ if (typeof candidate === "boolean")
243
+ return candidate;
244
+ }
245
+ return undefined;
246
+ }
247
+ function compactString(value, maxLength = 1200) {
248
+ if (typeof value !== "string" && typeof value !== "number") {
249
+ return undefined;
250
+ }
251
+ const compacted = String(value)
252
+ .replace(/[ \t]+\n/g, "\n")
253
+ .replace(/\n{3,}/g, "\n\n")
254
+ .trim();
255
+ if (!compacted)
256
+ return undefined;
257
+ return compacted.length > maxLength
258
+ ? `${compacted.slice(0, maxLength - 3).trim()}...`
259
+ : compacted;
260
+ }
261
+ function stripEmpty(value) {
262
+ if (Array.isArray(value)) {
263
+ return value
264
+ .map((item) => stripEmpty(item))
265
+ .filter((item) => {
266
+ if (item == null)
267
+ return false;
268
+ if (Array.isArray(item))
269
+ return item.length > 0;
270
+ if (isRecord(item))
271
+ return Object.keys(item).length > 0;
272
+ return true;
273
+ });
274
+ }
275
+ if (!isRecord(value))
276
+ return value;
277
+ const entries = Object.entries(value)
278
+ .map(([key, item]) => [key, stripEmpty(item)])
279
+ .filter(([, item]) => {
280
+ if (item == null || item === "")
281
+ return false;
282
+ if (Array.isArray(item))
283
+ return item.length > 0;
284
+ if (isRecord(item))
285
+ return Object.keys(item).length > 0;
286
+ return true;
287
+ });
288
+ return Object.fromEntries(entries);
289
+ }
290
+ function asArray(value) {
291
+ if (Array.isArray(value))
292
+ return value;
293
+ return value == null ? [] : [value];
294
+ }
295
+ function pickProfileArray(profile, keys) {
296
+ for (const key of keys) {
297
+ const value = profile[key];
298
+ if (Array.isArray(value))
299
+ return value;
300
+ }
301
+ return [];
302
+ }
303
+ function datePartText(value) {
304
+ if (typeof value === "string")
305
+ return compactString(value, 80);
306
+ if (!isRecord(value))
307
+ return undefined;
308
+ const text = pickStringFrom(value, ["text", "date"], 80);
309
+ if (text)
310
+ return text;
311
+ const year = pickNumberFrom(value, ["year"]);
312
+ const month = pickStringFrom(value, ["month"], 20) || pickNumberFrom(value, ["month"]);
313
+ if (!year)
314
+ return undefined;
315
+ return month ? `${month} ${year}` : String(year);
316
+ }
317
+ function dateRangeFromItem(item) {
318
+ const explicit = pickStringFrom(item, ["dateRange", "date_range", "period"], 120);
319
+ if (explicit)
320
+ return explicit;
321
+ const startText = datePartText(item.startDate) ||
322
+ datePartText(stripEmpty({
323
+ month: item.start_month ?? item.startMonth,
324
+ year: item.start_year ?? item.startYear,
325
+ }));
326
+ const endText = datePartText(item.endDate) ||
327
+ datePartText(stripEmpty({
328
+ month: item.end_month ?? item.endMonth,
329
+ year: item.end_year ?? item.endYear,
330
+ }));
331
+ return [startText, endText].filter(Boolean).join(" - ") || undefined;
332
+ }
333
+ function compactExperienceItem(value) {
334
+ if (!isRecord(value))
335
+ return {};
336
+ const companyRecord = pickRecord(value, ["company"]);
337
+ return stripEmpty({
338
+ title: pickStringFrom(value, ["title", "position", "job_title"], 160),
339
+ companyName: pickStringFrom(value, ["companyName", "company_name", "company"], 160) ||
340
+ pickStringFrom(companyRecord, ["name"], 160),
341
+ companyLinkedinUrl: pickStringFrom(value, [
342
+ "companyLinkedinUrl",
343
+ "companyLinkedInUrl",
344
+ "company_linkedin_url",
345
+ "companyLink",
346
+ "company_public_url",
347
+ ], 240),
348
+ dateRange: dateRangeFromItem(value),
349
+ duration: pickStringFrom(value, ["duration", "current_job_duration"], 80),
350
+ location: pickStringFrom(value, ["location"], 180),
351
+ description: pickStringFrom(value, ["description", "summary"], 700),
352
+ isCurrent: pickBooleanFrom(value, ["isCurrent", "is_current"]),
353
+ });
354
+ }
355
+ function compactEducationItem(value) {
356
+ if (!isRecord(value))
357
+ return {};
358
+ return stripEmpty({
359
+ schoolName: pickStringFrom(value, ["schoolName", "school_name", "school"], 180),
360
+ schoolLinkedinUrl: pickStringFrom(value, ["schoolLinkedinUrl", "schoolLinkedInUrl", "school_linkedin_url"], 240),
361
+ degree: pickStringFrom(value, ["degree"], 180),
362
+ fieldOfStudy: pickStringFrom(value, ["fieldOfStudy", "field_of_study"], 180),
363
+ dateRange: dateRangeFromItem(value),
364
+ });
365
+ }
366
+ function compactStringList(value, limit) {
367
+ return asArray(value)
368
+ .map((item) => {
369
+ if (typeof item === "string" || typeof item === "number") {
370
+ return compactString(item, 120);
371
+ }
372
+ return pickStringFrom(item, ["name", "title", "skill"], 120);
373
+ })
374
+ .filter((item) => Boolean(item))
375
+ .slice(0, limit);
376
+ }
377
+ function compactLanguages(value) {
378
+ return asArray(value)
379
+ .map((item) => {
380
+ if (typeof item === "string")
381
+ return { name: compactString(item, 80) };
382
+ if (!isRecord(item))
383
+ return {};
384
+ return stripEmpty({
385
+ name: pickStringFrom(item, ["name", "language"], 80),
386
+ proficiency: pickStringFrom(item, ["proficiency"], 120),
387
+ });
388
+ })
389
+ .filter((item) => Object.keys(item).length > 0)
390
+ .slice(0, 10);
391
+ }
392
+ function compactNamedItems(value) {
393
+ return asArray(value)
394
+ .map((item) => {
395
+ if (typeof item === "string")
396
+ return { name: compactString(item, 160) };
397
+ if (!isRecord(item))
398
+ return {};
399
+ return stripEmpty({
400
+ name: pickStringFrom(item, ["name", "title"], 180),
401
+ organization: pickStringFrom(item, ["organization", "authority", "issuer", "companyName"], 180),
402
+ dateRange: dateRangeFromItem(item),
403
+ description: pickStringFrom(item, ["description"], 500),
404
+ url: pickStringFrom(item, ["url", "link"], 240),
405
+ });
406
+ })
407
+ .filter((item) => Object.keys(item).length > 0)
408
+ .slice(0, 5);
409
+ }
410
+ function firstCurrentRole(profile) {
411
+ const current = pickProfileArray(profile, [
412
+ "currentPosition",
413
+ "current_position",
414
+ ])
415
+ .map(compactExperienceItem)
416
+ .find((item) => Object.keys(item).length > 0);
417
+ if (current)
418
+ return current;
419
+ const firstExperience = pickProfileArray(profile, [
420
+ "experience",
421
+ "experiences",
422
+ ])
423
+ .map(compactExperienceItem)
424
+ .find((item) => Object.keys(item).length > 0);
425
+ if (firstExperience)
426
+ return firstExperience;
427
+ return stripEmpty({
428
+ title: pickStringFrom(profile, ["job_title", "jobTitle", "title"], 160),
429
+ companyName: pickStringFrom(profile, ["company", "companyName"], 160),
430
+ dateRange: dateRangeFromItem(stripEmpty({
431
+ start_month: profile.current_company_join_month ?? profile.currentCompanyJoinMonth,
432
+ start_year: profile.current_company_join_year ?? profile.currentCompanyJoinYear,
433
+ })),
434
+ duration: pickStringFrom(profile, ["current_job_duration", "currentJobDuration"], 80),
435
+ });
436
+ }
437
+ function compactCompanyFromProfile(profile, currentRole) {
438
+ return stripEmpty({
439
+ name: pickStringFrom(profile, ["company", "companyName"], 180) ||
440
+ currentRole.companyName,
441
+ linkedinUrl: pickStringFrom(profile, [
442
+ "company_linkedin_url",
443
+ "companyLinkedinUrl",
444
+ "companyLinkedInUrl",
445
+ "currentCompanyLinkedinUrl",
446
+ ], 240) || currentRole.companyLinkedinUrl,
447
+ website: pickStringFrom(profile, ["company_website", "companyWebsite", "website"], 240),
448
+ domain: pickStringFrom(profile, ["company_domain", "companyDomain", "currentCompanyDomain"], 120),
449
+ industry: pickStringFrom(profile, ["company_industry", "industry"], 160),
450
+ employeeCount: pickNumberFrom(profile, [
451
+ "company_employee_count",
452
+ "companyEmployeeCount",
453
+ "employeeCount",
454
+ ]),
455
+ employeeRange: pickStringFrom(profile, ["company_employee_range", "companyEmployeeRange", "employeeCountRange"], 80),
456
+ description: pickStringFrom(profile, ["company_description", "companyDescription"], 700),
457
+ founded: pickStringFrom(profile, ["company_year_founded", "founded"], 80) ||
458
+ pickNumberFrom(profile, ["company_year_founded", "founded"]),
459
+ });
460
+ }
461
+ export function compactLinkedInProfile(profile) {
462
+ if (!isRecord(profile))
463
+ return {};
464
+ const currentRole = firstCurrentRole(profile);
465
+ const experience = pickProfileArray(profile, ["experience", "experiences"])
466
+ .map(compactExperienceItem)
467
+ .filter((item) => Object.keys(item).length > 0)
468
+ .slice(0, 5);
469
+ const education = pickProfileArray(profile, ["education", "educations"])
470
+ .map(compactEducationItem)
471
+ .filter((item) => Object.keys(item).length > 0)
472
+ .slice(0, 3);
473
+ const skills = compactStringList(profile.topSkills ?? profile.skills, 12);
474
+ return stripEmpty({
475
+ id: pickStringFrom(profile, ["id", "profile_id"], 180),
476
+ publicIdentifier: pickStringFrom(profile, ["publicIdentifier", "public_id", "publicIdentifier"], 120),
477
+ linkedinUrl: pickStringFrom(profile, ["linkedinUrl", "linkedin_url", "profile_url"], 240),
478
+ firstName: pickStringFrom(profile, ["firstName", "first_name"], 120),
479
+ lastName: pickStringFrom(profile, ["lastName", "last_name"], 120),
480
+ fullName: pickStringFrom(profile, ["fullName", "full_name", "name"], 180) ||
481
+ [
482
+ pickStringFrom(profile, ["firstName", "first_name"], 120),
483
+ pickStringFrom(profile, ["lastName", "last_name"], 120),
484
+ ]
485
+ .filter(Boolean)
486
+ .join(" "),
487
+ headline: pickStringFrom(profile, ["headline"], 320),
488
+ about: pickStringFrom(profile, ["about", "summary"], 1600),
489
+ location: pickStringFrom(profile, ["location"], 180),
490
+ followerCount: pickNumberFrom(profile, ["followerCount", "follower_count"]),
491
+ connectionCount: pickNumberFrom(profile, [
492
+ "connectionsCount",
493
+ "connection_count",
494
+ "connectionCount",
495
+ ]),
496
+ isPremium: pickBooleanFrom(profile, ["premium", "is_premium", "isPremium"]),
497
+ isCreator: pickBooleanFrom(profile, ["is_creator", "isCreator"]),
498
+ isVerified: pickBooleanFrom(profile, [
499
+ "verified",
500
+ "is_verified",
501
+ "isVerified",
502
+ ]),
503
+ currentRole,
504
+ company: compactCompanyFromProfile(profile, currentRole),
505
+ experience,
506
+ education,
507
+ skills,
508
+ languages: compactLanguages(profile.languages),
509
+ certifications: compactNamedItems(profile.certifications),
510
+ projects: compactNamedItems(profile.projects),
511
+ });
512
+ }
513
+ function canonicalizeLinkedInProfileResponse(raw) {
514
+ if (raw &&
515
+ typeof raw === "object" &&
516
+ raw.status === "success" &&
517
+ (raw.data || raw.person || raw.profile?.person)) {
518
+ const canonical = raw.data ?? raw.person ?? raw.profile?.person;
519
+ const { status, data, person, profile, ...rest } = raw;
520
+ return { status, profile: canonical, ...rest };
521
+ }
522
+ return raw;
523
+ }
524
+ function compactLinkedInProfileResponse(raw) {
525
+ const normalized = canonicalizeLinkedInProfileResponse(raw);
526
+ if (normalized &&
527
+ typeof normalized === "object" &&
528
+ normalized.status === "success" &&
529
+ normalized.profile) {
530
+ return {
531
+ status: normalized.status,
532
+ profile: compactLinkedInProfile(normalized.profile),
533
+ };
534
+ }
535
+ return normalized;
536
+ }
190
537
  function serializePostMedia(post) {
191
538
  const media = post.media && typeof post.media === "object"
192
539
  ? post.media
@@ -295,23 +642,9 @@ export async function fetchLinkedInProfile(linkedinUrl, options = {}) {
295
642
  params.set("full", "true");
296
643
  }
297
644
  const raw = await api.get(`/api/v1/scrape/linkedin/profile?${params.toString()}`);
298
- // Server returns the same profile object triplicated as `.data`,
299
- // `.person`, AND `.profile.person` for backwards-compat with web
300
- // callers (signal-discovery, profile-enrichment, canonical-cache, etc).
301
- // For the MCP layer, that ~3xs the JSON size and routinely breaches
302
- // Claude Code's max-token cap on tool results — which triggers the
303
- // harness's "save to file + read in chunks via python3" overflow
304
- // fallback. Strip the duplicates here so the agent gets one clean
305
- // copy of the data under a single `profile` key.
306
- if (raw &&
307
- typeof raw === "object" &&
308
- raw.status === "success" &&
309
- (raw.data || raw.person || raw.profile?.person)) {
310
- const canonical = raw.data ?? raw.person ?? raw.profile?.person;
311
- const { status, data, person, profile, ...rest } = raw;
312
- return { status, profile: canonical, ...rest };
313
- }
314
- return raw;
645
+ return options.fetchMinimal === false
646
+ ? canonicalizeLinkedInProfileResponse(raw)
647
+ : compactLinkedInProfileResponse(raw);
315
648
  }
316
649
  export async function fetchCompany(companyUrl) {
317
650
  const api = getApi();
@@ -20,6 +20,23 @@ function normalizeLeadSourceProvider(provider) {
20
20
  return "csv-linkedin";
21
21
  return undefined;
22
22
  }
23
+ function isPositiveNumber(value) {
24
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
25
+ }
26
+ function hasSignalDiscoveryProgressEvidence(rowCount, importProgress) {
27
+ const phase = importProgress?.phase ?? null;
28
+ return (rowCount > 0 ||
29
+ isPositiveNumber(importProgress?.processed) ||
30
+ isPositiveNumber(importProgress?.leadsImported) ||
31
+ isPositiveNumber(importProgress?.scrapedEngagers) ||
32
+ isPositiveNumber(importProgress?.evaluatedEngagers) ||
33
+ isPositiveNumber(importProgress?.evaluationTotal) ||
34
+ isPositiveNumber(importProgress?.activeEvaluationBatches) ||
35
+ isPositiveNumber(importProgress?.queuedForEvaluation) ||
36
+ isPositiveNumber(importProgress?.postsScraped) ||
37
+ phase === "scraping" ||
38
+ phase === "processing");
39
+ }
23
40
  function sleep(ms) {
24
41
  return new Promise((resolve) => setTimeout(resolve, ms));
25
42
  }
@@ -233,6 +250,8 @@ export async function waitForLeadListReady(input) {
233
250
  if (typeof configTarget === "number" && !targetLeadCount) {
234
251
  targetLeadCount = configTarget;
235
252
  }
253
+ const signalDiscoveryHasProgressEvidence = provider === "signal-discovery" &&
254
+ hasSignalDiscoveryProgressEvidence(rowCount, importProgress);
236
255
  let jobId = input.jobId;
237
256
  if (!jobId && typeof config?.importJobId === "string") {
238
257
  jobId = config.importJobId;
@@ -253,7 +272,9 @@ export async function waitForLeadListReady(input) {
253
272
  targetLeadCount,
254
273
  };
255
274
  }
256
- if (configError && configStatus !== "complete") {
275
+ if (configError &&
276
+ configStatus !== "complete" &&
277
+ !signalDiscoveryHasProgressEvidence) {
257
278
  return {
258
279
  ready: false,
259
280
  reason: "import_failed",
@@ -267,21 +288,31 @@ export async function waitForLeadListReady(input) {
267
288
  error: configError,
268
289
  };
269
290
  }
291
+ if (configError &&
292
+ configStatus !== "complete" &&
293
+ signalDiscoveryHasProgressEvidence) {
294
+ lastError = configError;
295
+ }
270
296
  if (configStatus) {
271
297
  lastStatus = configStatus;
272
298
  if (configStatus === "error") {
273
- return {
274
- ready: false,
275
- reason: "import_failed",
276
- leadListId,
277
- provider: provider ?? null,
278
- attempts,
279
- elapsedMs: Date.now() - start,
280
- rowCount,
281
- status: configStatus,
282
- targetLeadCount,
283
- error: configError,
284
- };
299
+ if (signalDiscoveryHasProgressEvidence) {
300
+ lastError = configError ?? lastError;
301
+ }
302
+ else {
303
+ return {
304
+ ready: false,
305
+ reason: "import_failed",
306
+ leadListId,
307
+ provider: provider ?? null,
308
+ attempts,
309
+ elapsedMs: Date.now() - start,
310
+ rowCount,
311
+ status: configStatus,
312
+ targetLeadCount,
313
+ error: configError,
314
+ };
315
+ }
285
316
  }
286
317
  if (configStatus === "complete") {
287
318
  importComplete = true;
@@ -6313,6 +6313,7 @@ export declare const allTools: ({
6313
6313
  postUrl?: undefined;
6314
6314
  sources?: undefined;
6315
6315
  full?: undefined;
6316
+ fetchMinimal?: undefined;
6316
6317
  companyUrl?: undefined;
6317
6318
  sortBy?: undefined;
6318
6319
  linkedin_url?: undefined;
@@ -6343,6 +6344,7 @@ export declare const allTools: ({
6343
6344
  };
6344
6345
  linkedinUrl?: undefined;
6345
6346
  full?: undefined;
6347
+ fetchMinimal?: undefined;
6346
6348
  companyUrl?: undefined;
6347
6349
  sortBy?: undefined;
6348
6350
  linkedin_url?: undefined;
@@ -6365,6 +6367,11 @@ export declare const allTools: ({
6365
6367
  description: string;
6366
6368
  default: boolean;
6367
6369
  };
6370
+ fetchMinimal: {
6371
+ type: string;
6372
+ description: string;
6373
+ default: boolean;
6374
+ };
6368
6375
  limit?: undefined;
6369
6376
  postUrl?: undefined;
6370
6377
  sources?: undefined;
@@ -6390,6 +6397,7 @@ export declare const allTools: ({
6390
6397
  postUrl?: undefined;
6391
6398
  sources?: undefined;
6392
6399
  full?: undefined;
6400
+ fetchMinimal?: undefined;
6393
6401
  sortBy?: undefined;
6394
6402
  linkedin_url?: undefined;
6395
6403
  max_posts?: undefined;
@@ -6421,6 +6429,7 @@ export declare const allTools: ({
6421
6429
  postUrl?: undefined;
6422
6430
  sources?: undefined;
6423
6431
  full?: undefined;
6432
+ fetchMinimal?: undefined;
6424
6433
  linkedin_url?: undefined;
6425
6434
  max_posts?: undefined;
6426
6435
  };
@@ -6446,6 +6455,7 @@ export declare const allTools: ({
6446
6455
  postUrl?: undefined;
6447
6456
  sources?: undefined;
6448
6457
  full?: undefined;
6458
+ fetchMinimal?: undefined;
6449
6459
  companyUrl?: undefined;
6450
6460
  sortBy?: undefined;
6451
6461
  };
@@ -6466,6 +6476,7 @@ export declare const allTools: ({
6466
6476
  postUrl?: undefined;
6467
6477
  sources?: undefined;
6468
6478
  full?: undefined;
6479
+ fetchMinimal?: undefined;
6469
6480
  companyUrl?: undefined;
6470
6481
  sortBy?: undefined;
6471
6482
  max_posts?: undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.318",
3
+ "version": "0.1.319",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -111,6 +111,10 @@ Blueprint rules:
111
111
  - Use stable, readable column ids such as `linkedin_url`, `enrich_prospect`, and
112
112
  `score_icp_mcp`.
113
113
  - Use registry type keys exactly, not display names.
114
+ - Treat the API column registry as the source of truth for type validation.
115
+ MCP code must not maintain its own hardcoded column-type allowlist; if the API
116
+ returns `phantom_type`, `deprecated_type`, or `not_createable_type`, fix the
117
+ blueprint to match the current registry response.
114
118
  - Treat "Enrich Prospect" as a `http_request` preset, not its own type.
115
119
  - Use `score_icp_mcp` for new AI ICP scoring. Do not use legacy
116
120
  `score_icp` or the old `score_icp_rubric` alias; they are reserved for older
@@ -145,6 +149,28 @@ Blueprint rules:
145
149
  synced sender connections, a positive `check_connection`, or accepted invite
146
150
  proof from the current flow.
147
151
 
152
+ Config rules:
153
+
154
+ - Put native column settings in `config`. The commit path preserves full config
155
+ objects and validates them server-side before mutation.
156
+ - Use blueprint-level `inputMapping` for references to existing producer
157
+ columns. The commit path materializes those references into production
158
+ template mappings such as `{{<actual column id>}}` or
159
+ `{{<actual Enrich Prospect column id>.id}}`.
160
+ - Use `runCondition` for branch gates that should exist in production. Template
161
+ tokens in `config`, AI prompts, formula expressions, and `runCondition` are
162
+ dependency-bearing and must point to real producer columns.
163
+ - For outbound HTTP throttling, set `http_request.config.rateLimit` as either
164
+ `{ "mode": "window", "maxRequests": N, "windowSeconds": S }` or
165
+ `{ "mode": "concurrency", "maxConcurrent": N }`.
166
+ - For public webhook intake throttling, set
167
+ `inbound_webhook.config.rateLimit` as
168
+ `{ "maxRequests": N, "windowSeconds": S }`. Omit it to use the server default.
169
+ - Do not invent per-column `rateLimit` for LinkedIn action columns. Those are
170
+ governed by sender/account limits and scheduling windows; configure action
171
+ behavior with `actionConfig`, `waitForEvent`, `runCondition`, and producer
172
+ mappings.
173
+
148
174
  ## Commit phase
149
175
 
150
176
  Use exactly one tool for the initial commit:
@@ -55,10 +55,11 @@ read/edit/delete safety, but they are not valid `add_column` or
55
55
 
56
56
  - type: `datetime`
57
57
  - displayName: `Date/Time`
58
+ - lifecycle: hidden active, load-only; not a valid create target.
58
59
  - category: Lead Data
59
60
  - inputs: none.
60
61
  - outputs: ISO-like date/time value.
61
- - when to use: Use for system-owned scheduling timestamps.
62
+ - when to use: Existing tables may contain system-owned scheduling timestamps.
62
63
  - when NOT to use: Do not use to wait; use `wait` for sequence timing.
63
64
 
64
65
  ### Next Action
@@ -117,6 +118,8 @@ read/edit/delete safety, but they are not valid `add_column` or
117
118
  - displayName: `Inbound Webhook`
118
119
  - category: Lead Data
119
120
  - inputs: none.
121
+ - config: optional `rateLimit` as `{ "maxRequests": N, "windowSeconds": S }`;
122
+ server defaults to 100 requests per 60 seconds when omitted.
120
123
  - outputs: received JSON payload and webhook metadata.
121
124
  - when to use: Use when rows are populated by external webhook events.
122
125
  - when NOT to use: Do not use for polling APIs; use `http_request`.
@@ -163,6 +166,8 @@ read/edit/delete safety, but they are not valid `add_column` or
163
166
  - displayName: `Check Open Profile`
164
167
  - category: LinkedIn Actions
165
168
  - inputs: required `linkedin_url`.
169
+ - config: may use the same `rateLimit` shape as `http_request` for provider
170
+ lookup throttling.
166
171
  - outputs: boolean open-profile decision and lookup metadata.
167
172
  - when to use: Use before selecting open-profile InMail behavior.
168
173
  - when NOT to use: Do not use to check first-degree connection; use `check_connection`.
@@ -296,6 +301,11 @@ read/edit/delete safety, but they are not valid `add_column` or
296
301
 
297
302
  ## Data & Logic
298
303
 
304
+ LinkedIn action columns do not accept a generic per-column `rateLimit`. They
305
+ use sender/account daily limits, sending hours, and action scheduling outside
306
+ the blueprint column config. Use `actionConfig.waitForEvent`, `runCondition`,
307
+ and explicit producer mappings for action behavior.
308
+
299
309
  ### AI Column
300
310
 
301
311
  - type: `ai_column`
@@ -332,6 +342,10 @@ read/edit/delete safety, but they are not valid `add_column` or
332
342
  - displayName: `HTTP Request`
333
343
  - category: Data & Logic
334
344
  - inputs: required `method`, required `endpoint`, optional `headers`, optional `body`.
345
+ - config: optional `rateLimit` supports rolling windows
346
+ `{ "mode": "window", "maxRequests": N, "windowSeconds": S }` and concurrency
347
+ lanes `{ "mode": "concurrency", "maxConcurrent": N }`. Omit it to use the
348
+ server's default I/O concurrency lane.
335
349
  - outputs: HTTP status, response body, and metadata; Enrich Prospect also returns root `id` as the EnrichedProspect id.
336
350
  - when to use: Use for REST API calls, including the canonical "Enrich Prospect" preset at `/api/v4/enrich-prospect`.
337
351
  - when NOT to use: Do not use when a first-party typed column already handles the action.