fullstackgtm 0.50.1 → 0.51.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ The path to 1.0 is planned in [docs/roadmap-to-1.0.md](https://github.com/fullst
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.0] — 2026-07-11
11
+
12
+ ### Added
13
+
14
+ - Native Clay Public API authentication and people search for governed lead
15
+ acquisition, including ICP-to-Clay filter translation and resumable search
16
+ checkpoints.
17
+ - A provider-neutral, fill-only contact waterfall contract for work email,
18
+ mobile, and direct-dial enrichment, with Pipe0 as the first registered
19
+ provider and compatibility for existing Pipe0 email resolution.
20
+ - Clay acquisition presets, initialization, doctor diagnostics, public API
21
+ exports, strategy documentation, and live-safe credential validation.
22
+
23
+ ### Changed
24
+
25
+ - `enrich acquire` now uses the shared decision-first presentation contract:
26
+ compact cards by default, full evidence with `--verbose`, and a stable JSON
27
+ envelope with counts, cost, meter, and persistence state under `--json`.
28
+ - Pre-enrichment deduplication and avoided-spend reporting are provider-neutral
29
+ so the same acquisition pipeline can support additional contact sources.
30
+
10
31
  ## [0.50.1] — 2026-07-10
11
32
 
12
33
  ### Added
package/dist/cli/auth.js CHANGED
@@ -7,6 +7,7 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
7
7
  import { activeWorkspaceProfile, readHealthTimeline, summarizeHealth } from "../health.js";
8
8
  import { resolveLlmCredential, validateLlmKey } from "../llm.js";
9
9
  import { createFilePlanStore } from "../planStore.js";
10
+ import { validateClayApiKey } from "../connectors/clay.js";
10
11
  import { isOptionValue, numericOption, option, readPackageInfo, readSecret } from "./shared.js";
11
12
  import { box, colorEnabled, paint, scoreColor, sparkline } from "./ui.js";
12
13
  /**
@@ -351,6 +352,22 @@ export async function login(args) {
351
352
  console.log(`Stored Apollo API key in ${credentialsPath()}. \`fullstackgtm enrich append|refresh\` use it automatically.`);
352
353
  return;
353
354
  }
355
+ if (provider === "clay") {
356
+ rejectArgvSecret(args, "--token", "--key", "--api-key");
357
+ const key = await readSecret("Clay Public API key");
358
+ if (!key)
359
+ throw new Error("No Clay Public API key provided.");
360
+ if (!args.includes("--no-validate")) {
361
+ const validation = await validateClayApiKey(key);
362
+ if (!validation.ok)
363
+ throw new Error(validation.detail);
364
+ console.log(validation.detail);
365
+ }
366
+ const stamp = new Date().toISOString();
367
+ storeCredential("clay", { kind: "api_key", accessToken: key, createdAt: stamp, updatedAt: stamp });
368
+ console.log(`Stored Clay Public API key in ${credentialsPath()}. Clay connector commands resolve it automatically.`);
369
+ return;
370
+ }
354
371
  if (provider === "pipe0" || provider === "explorium" || provider === "heyreach" || provider === "theirstack") {
355
372
  rejectArgvSecret(args, "--token", "--key", "--api-key");
356
373
  const key = await readSecret(`${provider} API key`);
@@ -365,7 +382,7 @@ export async function login(args) {
365
382
  return;
366
383
  }
367
384
  if (provider !== "hubspot") {
368
- throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, pipe0, explorium, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
385
+ throw new Error("login supports: hubspot, salesforce, stripe, anthropic, openai, apollo, clay, pipe0, explorium, heyreach, theirstack, or --via <hosted url>. Usage: fullstackgtm login <provider> | fullstackgtm login --via https://gtm.example.com");
369
386
  }
370
387
  const now = new Date().toISOString();
371
388
  rejectArgvSecret(args, "--token");
@@ -450,6 +467,9 @@ export function doctorReport(env = process.env) {
450
467
  stripe: env.STRIPE_SECRET_KEY
451
468
  ? { source: "env", detail: "STRIPE_SECRET_KEY" }
452
469
  : providerStatus("stripe", broker),
470
+ clay: env.CLAY_API_KEY
471
+ ? { source: "env", detail: "CLAY_API_KEY" }
472
+ : providerStatus("clay", null),
453
473
  };
454
474
  const llm = resolveLlmCredential(env);
455
475
  const missingPeers = ["@modelcontextprotocol/sdk", "zod"].filter((name) => {
@@ -461,13 +481,13 @@ export function doctorReport(env = process.env) {
461
481
  return true;
462
482
  }
463
483
  });
464
- const connected = Object.entries(providers).filter(([, status]) => status.source !== "none");
465
- const nextSteps = connected.length === 0
484
+ const connectedCrm = ["hubspot", "salesforce"].filter((provider) => providers[provider].source !== "none");
485
+ const nextSteps = connectedCrm.length === 0
466
486
  ? [
467
487
  "fullstackgtm audit --demo # no credentials needed",
468
488
  "fullstackgtm login hubspot # hosted browser OAuth (default; or: login salesforce)",
469
489
  ]
470
- : [`fullstackgtm audit --provider ${connected[0][0]}`];
490
+ : [`fullstackgtm audit --provider ${connectedCrm[0]}`];
471
491
  return {
472
492
  package: packageInfo,
473
493
  node: { version: process.versions.node, ok: nodeMajor >= 20, required: ">=20" },
@@ -8,6 +8,8 @@ import { createFilePlanStore } from "../planStore.js";
8
8
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor } from "../enrich.js";
9
9
  import { loadMeter, remaining } from "../acquireMeter.js";
10
10
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys } from "../connectors/prospectSources.js";
11
+ import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.js";
12
+ import { runContactWaterfall } from "../contactProviders.js";
11
13
  import { loadSeen, recordSeen } from "../acquireSeen.js";
12
14
  import { acquireCheckpointId, createFileAcquireCheckpointStore } from "../acquireCheckpoint.js";
13
15
  import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.js";
@@ -15,7 +17,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.js";
15
17
  import { progressReporter, reportCounts, reportEvent } from "../runReport.js";
16
18
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.js";
17
19
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.js";
18
- import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
20
+ import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp } from "../icp.js";
19
21
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords } from "../enrichApollo.js";
20
22
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.js";
21
23
  import { isOptionValue, loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -193,8 +195,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
193
195
  // Meter: how many MORE leads may we create right now? --max is an
194
196
  // additional per-run ceiling, never a way to exceed the budget.
195
197
  if (apiSkippedCrm || apiSkippedSeen) {
196
- console.error(`Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
197
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`);
198
+ console.error(`Pre-enrichment dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} previously seen ` +
199
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} downstream spend avoided).`);
198
200
  }
199
201
  // `cap` is the desired create count bounded by the persistent meter. Raw
200
202
  // discovery scanning has its own --scan-limit and may cross many duplicate-
@@ -264,16 +266,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
264
266
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
265
267
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
266
268
  if (!save) {
267
- if (rest.includes("--json")) {
268
- console.log(JSON.stringify({ plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom }, null, 2));
269
- }
270
- else {
271
- console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
272
- console.log(meterLine);
273
- if (gaugeLine)
274
- console.log(gaugeLine);
275
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
276
- }
269
+ printAcquireOutput({
270
+ args: rest,
271
+ result,
272
+ meter: headroom,
273
+ meterLine,
274
+ gaugeLine,
275
+ saved: false,
276
+ planSaved: false,
277
+ hostedPlanUrl: null,
278
+ });
277
279
  return;
278
280
  }
279
281
  const run = await openEnrichRun(store, source, "acquire", option(rest, "--run-label"), today);
@@ -319,28 +321,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
319
321
  if (acquireTelemetry && acquireCheckpointSync) {
320
322
  await persistAcquireCheckpoint(acquireCheckpointSync.key, acquireTelemetry.discovery, acquireCheckpointSync.hostedVersion);
321
323
  }
322
- if (planIds.length > 0) {
323
- const hostedRuns = hostedRunsUrl();
324
- console.log(`\nSaved plan ${result.plan.id}`);
325
- console.log(` Leads ${result.counts.created} net-new`);
326
- console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
327
- console.log(` Budget ${gaugeLine ?? meterLine}`);
328
- if (hostedPlanUrl)
329
- console.log(` Plan ${hostedPlanUrl}`);
330
- if (hostedRuns)
331
- console.log(` Observe ${hostedRuns}`);
332
- console.log("\nNext steps");
333
- console.log(` Review fullstackgtm plans show ${result.plan.id}`);
334
- console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
335
- console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
336
- console.log("\nThe meter is charged only when a create lands at apply.");
337
- }
338
- else {
339
- console.log(meterLine);
340
- if (gaugeLine)
341
- console.log(gaugeLine);
342
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
343
- }
324
+ printAcquireOutput({
325
+ args: rest,
326
+ result,
327
+ meter: headroom,
328
+ meterLine,
329
+ gaugeLine,
330
+ saved: true,
331
+ planSaved: planIds.length > 0,
332
+ hostedPlanUrl,
333
+ });
344
334
  return;
345
335
  }
346
336
  const mode = subcommand === "refresh" ? "refresh" : "append";
@@ -501,8 +491,9 @@ function formatEnrichCounts(counts, ambiguities) {
501
491
  /**
502
492
  * Pull net-new prospects from an API acquire source into source records the
503
493
  * acquire builder dedupes + turns into create_record ops. Explorium discovers;
504
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
505
- * carry the dedupe key (email) survive you cannot resolve-first without it.
494
+ * A field-level provider waterfall fills missing contact data. Existing
495
+ * email-keyed and resolveEmailsWith=pipe0 configs translate to an implicit
496
+ * Pipe0 work-email step. Only rows that carry the configured dedupe key survive.
506
497
  */
507
498
  async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRun, targetFresh, persistCheckpoint, progress) {
508
499
  const acquire = config.acquire;
@@ -521,7 +512,9 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
521
512
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
522
513
  : disc.provider === "pipe0"
523
514
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
524
- : {};
515
+ : disc.provider === "clay"
516
+ ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
517
+ : {};
525
518
  const queryFingerprint = createHash("sha256")
526
519
  .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
527
520
  .digest("hex");
@@ -584,6 +577,20 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
584
577
  cursor = result.nextCursor;
585
578
  exhausted = result.nextCursor === null;
586
579
  }
580
+ else if (disc.provider === "clay") {
581
+ if (!cursor) {
582
+ progress.note("creating Clay people-search iterator");
583
+ cursor = await createClaySearch({
584
+ apiKey: providerKey("clay"), sourceType: "people", filters,
585
+ });
586
+ }
587
+ const result = await runClayPeopleSearchPage({
588
+ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
589
+ });
590
+ page = result.prospects;
591
+ offset += page.length;
592
+ exhausted = !result.hasMore;
593
+ }
587
594
  else if (disc.provider === "explorium") {
588
595
  const pageNumber = offset + 1;
589
596
  exploriumPage = pageNumber;
@@ -604,7 +611,7 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
604
611
  exhausted = page.length < requestSize;
605
612
  }
606
613
  else {
607
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
614
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (clay | explorium | pipe0 | linkedin).`);
608
615
  }
609
616
  discovered += page.length;
610
617
  progress.items(discovered, scanLimit);
@@ -650,23 +657,35 @@ async function acquireFromApi(config, source, rest, icp, snapshot, seen, priorRu
650
657
  }
651
658
  progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
652
659
  progress.note("checking work-email requirements");
653
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
654
- // (Explorium's email is hashed, Crustdata returns none) or when a source
655
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
656
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
657
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
658
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
659
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
660
- // names. Resolve domains first (pipe0 company:identity) so resolution can
661
- // actually land — without it, name-only resolution fails for every lead.
662
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
663
- progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
664
- const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
665
- prospects.splice(0, prospects.length, ...resolved);
666
- }
667
- progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
668
- const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
669
- prospects.splice(0, prospects.length, ...resolved);
660
+ // 3. Resolve contact fields through the ordered, fill-only waterfall. Keep
661
+ // legacy behavior: email-keyed acquisition and resolveEmailsWith=pipe0 imply
662
+ // a Pipe0 work-email step when no explicit waterfall is configured.
663
+ const implicitPipe0 = matchKey === "email" || disc.resolveEmailsWith === "pipe0";
664
+ const contactWaterfall = disc.contactWaterfall ?? (implicitPipe0 ? [{ provider: "pipe0", fields: ["work_email"] }] : []);
665
+ if (contactWaterfall.length > 0) {
666
+ const adapters = {
667
+ pipe0: {
668
+ provider: "pipe0",
669
+ resolve: async (candidates, fields) => {
670
+ let resolved = candidates;
671
+ if (fields.includes("work_email") && resolved.some((p) => !p.companyDomain && p.companyName)) {
672
+ progress.note(`resolving company domains for ${resolved.length} candidate(s)`);
673
+ resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects: resolved });
674
+ }
675
+ if (fields.includes("work_email")) {
676
+ progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
677
+ resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
678
+ }
679
+ return resolved;
680
+ },
681
+ },
682
+ };
683
+ const result = await runContactWaterfall({ prospects, steps: contactWaterfall, adapters });
684
+ prospects.splice(0, prospects.length, ...result.prospects);
685
+ for (const attempt of result.attempts) {
686
+ const added = Object.entries(attempt.added).map(([field, count]) => `${count} ${field}`).join(", ") || "0 fields";
687
+ progress.note(`${attempt.provider}: ${attempt.attempted} attempted · ${added} added`);
688
+ }
670
689
  }
671
690
  progress.items(prospects.length, prospects.length);
672
691
  progress.note(`${prospects.length} candidate(s) ready for planning`);
@@ -756,6 +775,53 @@ function formatAcquireMeter(headroom, costPerRecord) {
756
775
  `Spend left: ${money(headroom.spendUsd.day)}/day, ${money(headroom.spendUsd.month)}/month ` +
757
776
  `(≈$${costPerRecord.toFixed(2)}/lead).`);
758
777
  }
778
+ function printAcquireOutput(options) {
779
+ const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
780
+ if (args.includes("--json")) {
781
+ console.log(JSON.stringify({
782
+ plan: result.plan,
783
+ counts: result.counts,
784
+ estCostUsd: result.estCostUsd,
785
+ meter,
786
+ persistence: {
787
+ runSaved: saved,
788
+ planSaved,
789
+ planId: planSaved ? result.plan.id : null,
790
+ hostedPlanUrl,
791
+ },
792
+ }, null, 2));
793
+ return;
794
+ }
795
+ if (verbosePlanRequested(args)) {
796
+ console.log(patchPlanToMarkdown(result.plan));
797
+ console.log(`\nAcquisition`);
798
+ console.log(` Leads ${result.counts.created} net-new · ${result.counts.unassigned} unassigned`);
799
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
800
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
801
+ if (hostedPlanUrl)
802
+ console.log(` Plan ${hostedPlanUrl}`);
803
+ }
804
+ else if (planSaved || !saved) {
805
+ console.log(compactPlan(result.plan, { saved: planSaved }));
806
+ console.log(meterLine);
807
+ if (gaugeLine)
808
+ console.log(gaugeLine);
809
+ }
810
+ else {
811
+ console.log(meterLine);
812
+ if (gaugeLine)
813
+ console.log(gaugeLine);
814
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
815
+ }
816
+ if (planSaved) {
817
+ console.log(`\nApprove fullstackgtm plans approve ${result.plan.id} --operations all`);
818
+ console.log(`Apply fullstackgtm apply --plan-id ${result.plan.id} --provider <hubspot|salesforce>`);
819
+ const hostedRuns = hostedRunsUrl();
820
+ if (hostedRuns)
821
+ console.log(`Observe ${hostedRuns}`);
822
+ console.log("The meter is charged only when a create lands at apply.");
823
+ }
824
+ }
759
825
  function hostedRunsUrl() {
760
826
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
761
827
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/dist/cli/help.js CHANGED
@@ -16,13 +16,16 @@ Usage:
16
16
  fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
17
17
  fullstackgtm login stripe [--no-validate]
18
18
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
19
- fullstackgtm login apollo store an Apollo API key for enrich pulls\n fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)\n fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin\n fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|heyreach|broker>
19
+ fullstackgtm login apollo | clay store a data-provider Public API key
20
+ fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
21
+ fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
22
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
20
23
 
21
24
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
22
25
  the process list and shell history. Pipe them on stdin or enter them at the
23
26
  interactive prompt:
24
27
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
25
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
28
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
26
29
  cold start: scaffold icp.json + enrich.config.json + a
27
30
  PLAYBOOK wired for this workspace (the CLI ships primitives,
28
31
  your agent is the orchestrator — see docs/recipes.md)
@@ -72,7 +75,7 @@ Usage:
72
75
  fullstackgtm tam estimate [--name <n>] [--icp <path>] (--accounts <n> | --source theirstack|explorium) (--acv <annual-usd> | --acv-from-crm --deal-period monthly|quarterly|annual) [--acv-basis account|buyer] [--buyers-per-account <n>] [--cross-checks <file.json>] [source options] [--json]
73
76
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
74
77
  fullstackgtm tam report [--name <n>] [--out <path>]
75
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
78
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
76
79
  size the reachable market FROM the ICP (a real account count ×
77
80
  ACV; buyers/account = the contact population target), then fill
78
81
  it: populate schedules plan-only enrich acquire --save (apply
@@ -276,7 +279,7 @@ export const HELP = {
276
279
  summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
277
280
  phase: "Setup",
278
281
  synopsis: [
279
- "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
282
+ "fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
280
283
  ],
281
284
  detail: "Cold start: writes a starter ICP, an acquire-ready enrich.config.json (with a visible assign seam so leads are never ownerless), and a PLAYBOOK wired with the cold-start + outbound-loop recipes for this workspace. Pure file-writer — no network, keeps existing files unless --force. The CLI ships governed primitives; your coding agent is the orchestrator (see docs/recipes.md).",
282
285
  seeAlso: ["icp", "enrich", "signals"],
@@ -289,7 +292,7 @@ export const HELP = {
289
292
  "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
290
293
  "fullstackgtm login --via <hosted url> pair with a team deployment",
291
294
  "fullstackgtm login stripe",
292
- "fullstackgtm login anthropic | openai | apollo",
295
+ "fullstackgtm login anthropic | openai | apollo | clay",
293
296
  ],
294
297
  detail: "HubSpot/Salesforce use hosted browser OAuth by default; BYO app/token paths are advanced. Secrets are NEVER passed as flags — pipe on stdin or enter at the prompt: `echo \"$TOKEN\" | fullstackgtm login hubspot --private-token`.",
295
298
  seeAlso: ["doctor", "logout", "profiles"],
@@ -297,7 +300,7 @@ export const HELP = {
297
300
  logout: {
298
301
  summary: "remove stored credentials for a provider",
299
302
  phase: "Setup",
300
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
303
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
301
304
  seeAlso: ["login", "doctor"],
302
305
  },
303
306
  doctor: {
package/dist/cli/init.js CHANGED
@@ -13,7 +13,7 @@ import { option } from "./shared.js";
13
13
  export function initCommand(args) {
14
14
  if (args.includes("--help") || args.includes("-h")) {
15
15
  console.log(`Usage:
16
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
16
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
17
17
 
18
18
  Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
19
19
  icp.json starter ICP (edit, or rebuild with \`icp interview\`)
@@ -25,8 +25,8 @@ docs/recipes.md for the full play set. Existing files are kept unless --force.`)
25
25
  return;
26
26
  }
27
27
  const source = (option(args, "--source") ?? "pipe0");
28
- if (!["pipe0", "explorium", "linkedin"].includes(source)) {
29
- throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
28
+ if (!["pipe0", "explorium", "clay", "linkedin"].includes(source)) {
29
+ throw new Error(`init: --source must be pipe0|explorium|clay|linkedin (got "${source}")`);
30
30
  }
31
31
  const provider = (option(args, "--provider") ?? "hubspot");
32
32
  if (!["hubspot", "salesforce"].includes(provider)) {
package/dist/cli/tam.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
2
2
  /** Provider API key: env override first, then the credential store (`login`). */
3
- export declare function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string;
3
+ export declare function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string;
4
4
  /**
5
5
  * `tam` — Total Addressable Market mapping. Estimate a defensible universe from
6
6
  * the ICP, then iteratively populate it via scheduled governed acquire runs and
package/dist/cli/tam.js CHANGED
@@ -5,6 +5,7 @@ import { getCredential } from "../credentials.js";
5
5
  import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown } from "../tam.js";
6
6
  import { probeExploriumBusinessCount } from "../connectors/prospectSources.js";
7
7
  import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.js";
8
+ import { clayApiKey } from "../connectors/clay.js";
8
9
  import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.js";
9
10
  import { scheduleCommand } from "./schedule.js";
10
11
  import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.js";
@@ -12,6 +13,8 @@ import { unknownSubcommandError } from "./suggest.js";
12
13
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
13
14
  /** Provider API key: env override first, then the credential store (`login`). */
14
15
  export function providerKey(provider) {
16
+ if (provider === "clay")
17
+ return clayApiKey();
15
18
  const envName = provider === "explorium"
16
19
  ? "EXPLORIUM_API_KEY"
17
20
  : provider === "pipe0"
@@ -41,7 +44,7 @@ export async function tamCommand(args) {
41
44
  fullstackgtm tam accounts [--name <n>] [--icp <path>] --source theirstack [--max <n>] [--dry-run | --confirm] [--max-credits <n>] [--usd-per-credit <r>] [--out <file.csv> | --json]
42
45
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
43
46
  fullstackgtm tam report [--name <n>] [--out <path>]
44
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
47
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
45
48
 
46
49
  Estimate the reachable market FROM your ICP: a real account count × a confirmed
47
50
  ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
@@ -0,0 +1,33 @@
1
+ import type { Prospect } from "./prospectSources.ts";
2
+ export declare const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
3
+ export type ClayKeyValidation = {
4
+ ok: boolean;
5
+ detail: string;
6
+ workspaceId?: string;
7
+ };
8
+ export type ClaySearchSourceType = "people" | "companies";
9
+ export type ClayPeopleSearchPage = {
10
+ prospects: Prospect[];
11
+ hasMore: boolean;
12
+ };
13
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
14
+ export declare function createClaySearch(opts: {
15
+ apiKey: string;
16
+ sourceType: ClaySearchSourceType;
17
+ filters: Record<string, unknown>;
18
+ fetchImpl?: typeof fetch;
19
+ apiBaseUrl?: string;
20
+ }): Promise<string>;
21
+ /** Consume the next people page from a Clay search iterator. */
22
+ export declare function runClayPeopleSearchPage(opts: {
23
+ apiKey: string;
24
+ searchId: string;
25
+ limit?: number;
26
+ fetchImpl?: typeof fetch;
27
+ apiBaseUrl?: string;
28
+ }): Promise<ClayPeopleSearchPage>;
29
+ export declare function normalizeClayPerson(value: unknown): Prospect;
30
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
31
+ export declare function validateClayApiKey(apiKey: string, fetchImpl?: typeof fetch, apiBaseUrl?: string): Promise<ClayKeyValidation>;
32
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
33
+ export declare function clayApiKey(env?: Record<string, string | undefined>): string;
@@ -0,0 +1,123 @@
1
+ import { getCredential } from "../credentials.js";
2
+ import { ProviderHttpError } from "../providerError.js";
3
+ export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
4
+ function clayHeaders(apiKey) {
5
+ return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
6
+ }
7
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
8
+ export async function createClaySearch(opts) {
9
+ const fetchImpl = opts.fetchImpl ?? fetch;
10
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
11
+ const response = await fetchImpl(`${base}/search/filters-mode`, {
12
+ method: "POST",
13
+ headers: clayHeaders(opts.apiKey),
14
+ body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
15
+ });
16
+ if (!response.ok)
17
+ throw new ProviderHttpError("Clay", "create search", response.status);
18
+ const body = await response.json();
19
+ const searchId = body.search_id ?? body.searchId;
20
+ if (typeof searchId !== "string" || !searchId)
21
+ throw new Error("Clay create search returned no search_id.");
22
+ return searchId;
23
+ }
24
+ /** Consume the next people page from a Clay search iterator. */
25
+ export async function runClayPeopleSearchPage(opts) {
26
+ const fetchImpl = opts.fetchImpl ?? fetch;
27
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
28
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
29
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
30
+ method: "POST",
31
+ headers: clayHeaders(opts.apiKey),
32
+ body: JSON.stringify({ limit }),
33
+ });
34
+ if (!response.ok)
35
+ throw new ProviderHttpError("Clay", "run people search", response.status);
36
+ const body = await response.json();
37
+ if (!Array.isArray(body.data))
38
+ throw new Error("Clay people search returned an invalid data array.");
39
+ const hasMore = body.has_more ?? body.hasMore;
40
+ if (typeof hasMore !== "boolean")
41
+ throw new Error("Clay people search returned no has_more flag.");
42
+ return { prospects: body.data.map(normalizeClayPerson), hasMore };
43
+ }
44
+ export function normalizeClayPerson(value) {
45
+ const row = value && typeof value === "object" ? value : {};
46
+ const location = row.structured_location && typeof row.structured_location === "object"
47
+ ? row.structured_location
48
+ : {};
49
+ const fullName = stringValue(row.name);
50
+ return {
51
+ firstName: stringValue(row.first_name),
52
+ lastName: stringValue(row.last_name),
53
+ fullName,
54
+ jobTitle: stringValue(row.latest_experience_title),
55
+ companyName: stringValue(row.latest_experience_company),
56
+ companyDomain: bareDomain(stringValue(row.domain)),
57
+ linkedin: normalizeLinkedin(stringValue(row.url)),
58
+ headline: undefined,
59
+ sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
60
+ location: {
61
+ city: stringValue(location.city),
62
+ state: stringValue(location.state),
63
+ region: stringValue(location.region),
64
+ country: stringValue(location.country),
65
+ countryCode: stringValue(location.country_iso),
66
+ },
67
+ };
68
+ }
69
+ function stringValue(value) {
70
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
71
+ }
72
+ function bareDomain(value) {
73
+ return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
74
+ }
75
+ function normalizeLinkedin(value) {
76
+ if (!value)
77
+ return undefined;
78
+ const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
79
+ return normalized.replace(/\/$/, "");
80
+ }
81
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
82
+ export async function validateClayApiKey(apiKey, fetchImpl = fetch, apiBaseUrl = CLAY_PUBLIC_API_BASE) {
83
+ let response;
84
+ try {
85
+ response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
86
+ headers: { "clay-api-key": apiKey, Accept: "application/json" },
87
+ });
88
+ }
89
+ catch (error) {
90
+ const detail = error instanceof Error ? error.message : String(error);
91
+ return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
92
+ }
93
+ if (!response.ok)
94
+ return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
95
+ let body;
96
+ try {
97
+ body = await response.json();
98
+ }
99
+ catch {
100
+ return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
101
+ }
102
+ const record = body && typeof body === "object" ? body : {};
103
+ const workspace = record.workspace && typeof record.workspace === "object"
104
+ ? record.workspace
105
+ : undefined;
106
+ const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
107
+ const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
108
+ ? String(rawWorkspaceId)
109
+ : undefined;
110
+ return {
111
+ ok: true,
112
+ detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
113
+ workspaceId,
114
+ };
115
+ }
116
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
117
+ export function clayApiKey(env = process.env) {
118
+ const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
119
+ if (key)
120
+ return key;
121
+ throw new Error('No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
122
+ '`fullstackgtm login clay`, or set CLAY_API_KEY.');
123
+ }
@@ -32,6 +32,18 @@ export type Prospect = {
32
32
  linkedin?: string;
33
33
  /** real work email once resolved (pipe0); never the hashed value */
34
34
  email?: string;
35
+ /** Validated mobile number when a contact provider explicitly returns one. */
36
+ mobile?: string;
37
+ /** Validated business direct dial when distinct from mobile. */
38
+ directDial?: string;
39
+ /** Person geography returned by identity-search providers. */
40
+ location?: {
41
+ city?: string;
42
+ state?: string;
43
+ region?: string;
44
+ country?: string;
45
+ countryCode?: string;
46
+ };
35
47
  /** ICP fit score 0..1, set by the acquire scorer */
36
48
  fitScore?: number;
37
49
  /** provider-native id for traceability */