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/src/cli/enrich.ts CHANGED
@@ -9,6 +9,8 @@ import { createFilePlanStore } from "../planStore.ts";
9
9
  import { buildAcquirePlan, buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, builtinAcquirePreset, builtinEnrichPreset, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, type EnrichConfig, type EnrichCounts, type EnrichObjectType, type EnrichRun, type EnrichRunStore, type EnrichSourceRecord } from "../enrich.ts";
10
10
  import { loadMeter, remaining, type AcquireRemaining } from "../acquireMeter.ts";
11
11
  import { crmContactKeys, fetchExploriumProspects, fetchPipe0CrustdataProspectPage, partitionFreshProspects, pipe0ResolveCompanyDomains, pipe0ResolveWorkEmails, prospectIdentityKeys, type Prospect } from "../connectors/prospectSources.ts";
12
+ import { createClaySearch, runClayPeopleSearchPage } from "../connectors/clay.ts";
13
+ import { runContactWaterfall, type ContactProviderAdapter, type ContactWaterfallStep } from "../contactProviders.ts";
12
14
  import { loadSeen, recordSeen } from "../acquireSeen.ts";
13
15
  import { acquireCheckpointId, createFileAcquireCheckpointStore, type AcquireCheckpointKey } from "../acquireCheckpoint.ts";
14
16
  import { readHostedAcquireCheckpoint, writeHostedAcquireCheckpoint } from "../hostedAcquireCheckpoint.ts";
@@ -16,7 +18,7 @@ import { uploadHostedPatchPlan } from "../hostedPatchPlan.ts";
16
18
  import { progressReporter, reportCounts, reportEvent } from "../runReport.ts";
17
19
  import { ACQUIRE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
18
20
  import { createLinkedInProvider, discoverLinkedInProspects } from "../acquireLinkedIn.ts";
19
- import { fitThreshold, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
21
+ import { fitThreshold, icpToClayPeopleFilters, icpToCrustdataFilters, icpToExploriumFilters, scoreProspectAgainstIcp, type Icp } from "../icp.ts";
20
22
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, type ApolloPullKey } from "../enrichApollo.ts";
21
23
  import type { CanonicalGtmSnapshot } from "../types.ts";
22
24
  import { isSpoolPath, readSpoolPath } from "../spoolFiles.ts";
@@ -217,8 +219,8 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
217
219
  // additional per-run ceiling, never a way to exceed the budget.
218
220
  if (apiSkippedCrm || apiSkippedSeen) {
219
221
  console.error(
220
- `Pre-email dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} seen before paying for emails ` +
221
- `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} enrichment saved).`,
222
+ `Pre-enrichment dedup: skipped ${apiSkippedCrm} already-in-CRM + ${apiSkippedSeen} previously seen ` +
223
+ `(≈$${((apiSkippedCrm + apiSkippedSeen) * costPerRecord).toFixed(2)} downstream spend avoided).`,
222
224
  );
223
225
  }
224
226
  // `cap` is the desired create count bounded by the persistent meter. Raw
@@ -301,20 +303,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
301
303
  const meterLine = formatAcquireMeter(headroom, costPerRecord);
302
304
  const gaugeLine = acquireGaugeLine(headroom, config.acquire.budget ?? {}, paint(colorEnabled(process.stdout)));
303
305
  if (!save) {
304
- if (rest.includes("--json")) {
305
- console.log(
306
- JSON.stringify(
307
- { plan: result.plan, counts: result.counts, estCostUsd: result.estCostUsd, meter: headroom },
308
- null,
309
- 2,
310
- ),
311
- );
312
- } else {
313
- console.log(verbosePlanRequested(rest) ? patchPlanToMarkdown(result.plan) : compactPlan(result.plan));
314
- console.log(meterLine);
315
- if (gaugeLine) console.log(gaugeLine);
316
- console.log("\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.");
317
- }
306
+ printAcquireOutput({
307
+ args: rest,
308
+ result,
309
+ meter: headroom,
310
+ meterLine,
311
+ gaugeLine,
312
+ saved: false,
313
+ planSaved: false,
314
+ hostedPlanUrl: null,
315
+ });
318
316
  return;
319
317
  }
320
318
 
@@ -363,24 +361,16 @@ are phase 2. Recurring execution is the scheduler's job; enrich has no cron.`);
363
361
  acquireCheckpointSync.hostedVersion,
364
362
  );
365
363
  }
366
- if (planIds.length > 0) {
367
- const hostedRuns = hostedRunsUrl();
368
- console.log(`\nSaved plan ${result.plan.id}`);
369
- console.log(` Leads ${result.counts.created} net-new`);
370
- console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
371
- console.log(` Budget ${gaugeLine ?? meterLine}`);
372
- if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
373
- if (hostedRuns) console.log(` Observe ${hostedRuns}`);
374
- console.log("\nNext steps");
375
- console.log(` Review fullstackgtm plans show ${result.plan.id}`);
376
- console.log(` Approve fullstackgtm plans approve ${result.plan.id} --operations all`);
377
- console.log(` Apply fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot`);
378
- console.log("\nThe meter is charged only when a create lands at apply.");
379
- } else {
380
- console.log(meterLine);
381
- if (gaugeLine) console.log(gaugeLine);
382
- console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
383
- }
364
+ printAcquireOutput({
365
+ args: rest,
366
+ result,
367
+ meter: headroom,
368
+ meterLine,
369
+ gaugeLine,
370
+ saved: true,
371
+ planSaved: planIds.length > 0,
372
+ hostedPlanUrl,
373
+ });
384
374
  return;
385
375
  }
386
376
 
@@ -561,8 +551,9 @@ function formatEnrichCounts(counts: EnrichCounts, ambiguities: number) {
561
551
  /**
562
552
  * Pull net-new prospects from an API acquire source into source records the
563
553
  * acquire builder dedupes + turns into create_record ops. Explorium discovers;
564
- * pipe0 (when resolveEmailsWith=pipe0) fills real work emails. Only rows that
565
- * carry the dedupe key (email) survive you cannot resolve-first without it.
554
+ * A field-level provider waterfall fills missing contact data. Existing
555
+ * email-keyed and resolveEmailsWith=pipe0 configs translate to an implicit
556
+ * Pipe0 work-email step. Only rows that carry the configured dedupe key survive.
566
557
  */
567
558
  async function acquireFromApi(
568
559
  config: EnrichConfig,
@@ -602,7 +593,9 @@ async function acquireFromApi(
602
593
  ? (icp ? icpToExploriumFilters(icp) : (disc.filters ?? {}))
603
594
  : disc.provider === "pipe0"
604
595
  ? (icp ? icpToCrustdataFilters(icp) : (disc.filters ?? {}))
605
- : {};
596
+ : disc.provider === "clay"
597
+ ? (icp ? icpToClayPeopleFilters(icp) : (disc.filters ?? {}))
598
+ : {};
606
599
  const queryFingerprint = createHash("sha256")
607
600
  .update(JSON.stringify({ source, provider: disc.provider, listId, filters, icp: icp ?? null }))
608
601
  .digest("hex");
@@ -677,6 +670,19 @@ async function acquireFromApi(
677
670
  page = result.prospects;
678
671
  cursor = result.nextCursor;
679
672
  exhausted = result.nextCursor === null;
673
+ } else if (disc.provider === "clay") {
674
+ if (!cursor) {
675
+ progress.note("creating Clay people-search iterator");
676
+ cursor = await createClaySearch({
677
+ apiKey: providerKey("clay"), sourceType: "people", filters,
678
+ });
679
+ }
680
+ const result = await runClayPeopleSearchPage({
681
+ apiKey: providerKey("clay"), searchId: cursor, limit: requestSize,
682
+ });
683
+ page = result.prospects;
684
+ offset += page.length;
685
+ exhausted = !result.hasMore;
680
686
  } else if (disc.provider === "explorium") {
681
687
  const pageNumber = offset + 1;
682
688
  exploriumPage = pageNumber;
@@ -695,7 +701,7 @@ async function acquireFromApi(
695
701
  offset += page.length;
696
702
  exhausted = page.length < requestSize;
697
703
  } else {
698
- throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (explorium | pipe0 | linkedin).`);
704
+ throw new Error(`enrich acquire: unknown discovery provider "${disc.provider}" (clay | explorium | pipe0 | linkedin).`);
699
705
  }
700
706
  discovered += page.length;
701
707
  progress.items(discovered, scanLimit);
@@ -747,23 +753,37 @@ async function acquireFromApi(
747
753
  progress.stage(ACQUIRE_STAGES[1], 1, ACQUIRE_STAGES.length);
748
754
  progress.note("checking work-email requirements");
749
755
 
750
- // 3. Resolve real work emails. Triggered either when email IS the dedupe key
751
- // (Explorium's email is hashed, Crustdata returns none) or when a source
752
- // that keys on something else opts in via `resolveEmailsWith: "pipe0"`
753
- // (e.g. LinkedIn keys on the profile URL but still wants outreach emails).
754
- // pipe0 waterfall, chunked; resolves from name + company domain/name.
755
- if (matchKey === "email" || disc.resolveEmailsWith === "pipe0") {
756
- // The waterfall needs a company DOMAIN; LinkedIn/HeyReach lists carry only
757
- // names. Resolve domains first (pipe0 company:identity) so resolution can
758
- // actually land — without it, name-only resolution fails for every lead.
759
- if (prospects.some((p) => !p.companyDomain && p.companyName)) {
760
- progress.note(`resolving company domains for ${prospects.length} candidate(s)`);
761
- const resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects });
762
- prospects.splice(0, prospects.length, ...resolved);
756
+ // 3. Resolve contact fields through the ordered, fill-only waterfall. Keep
757
+ // legacy behavior: email-keyed acquisition and resolveEmailsWith=pipe0 imply
758
+ // a Pipe0 work-email step when no explicit waterfall is configured.
759
+ const implicitPipe0 = matchKey === "email" || disc.resolveEmailsWith === "pipe0";
760
+ const contactWaterfall: ContactWaterfallStep[] = disc.contactWaterfall ?? (
761
+ implicitPipe0 ? [{ provider: "pipe0", fields: ["work_email"] }] : []
762
+ );
763
+ if (contactWaterfall.length > 0) {
764
+ const adapters: Record<string, ContactProviderAdapter> = {
765
+ pipe0: {
766
+ provider: "pipe0",
767
+ resolve: async (candidates, fields) => {
768
+ let resolved = candidates;
769
+ if (fields.includes("work_email") && resolved.some((p) => !p.companyDomain && p.companyName)) {
770
+ progress.note(`resolving company domains for ${resolved.length} candidate(s)`);
771
+ resolved = await pipe0ResolveCompanyDomains({ apiKey: providerKey("pipe0"), prospects: resolved });
772
+ }
773
+ if (fields.includes("work_email")) {
774
+ progress.note(`resolving work emails with pipe0 for ${resolved.length} candidate(s)`);
775
+ resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects: resolved });
776
+ }
777
+ return resolved;
778
+ },
779
+ },
780
+ };
781
+ const result = await runContactWaterfall({ prospects, steps: contactWaterfall, adapters });
782
+ prospects.splice(0, prospects.length, ...result.prospects);
783
+ for (const attempt of result.attempts) {
784
+ const added = Object.entries(attempt.added).map(([field, count]) => `${count} ${field}`).join(", ") || "0 fields";
785
+ progress.note(`${attempt.provider}: ${attempt.attempted} attempted · ${added} added`);
763
786
  }
764
- progress.note(`resolving work emails for ${prospects.length} candidate(s)`);
765
- const resolved = await pipe0ResolveWorkEmails({ apiKey: providerKey("pipe0"), prospects });
766
- prospects.splice(0, prospects.length, ...resolved);
767
787
  }
768
788
  progress.items(prospects.length, prospects.length);
769
789
  progress.note(`${prospects.length} candidate(s) ready for planning`);
@@ -876,6 +896,61 @@ function formatAcquireMeter(headroom: AcquireRemaining, costPerRecord: number):
876
896
  );
877
897
  }
878
898
 
899
+ function printAcquireOutput(options: {
900
+ args: readonly string[];
901
+ result: ReturnType<typeof buildAcquirePlan>;
902
+ meter: AcquireRemaining;
903
+ meterLine: string;
904
+ gaugeLine: string | null;
905
+ /** The acquisition run/checkpoint was persisted. */
906
+ saved: boolean;
907
+ /** A non-empty plan was persisted to the plan store. */
908
+ planSaved: boolean;
909
+ hostedPlanUrl: string | null;
910
+ }): void {
911
+ const { args, result, meter, meterLine, gaugeLine, saved, planSaved, hostedPlanUrl } = options;
912
+ if (args.includes("--json")) {
913
+ console.log(JSON.stringify({
914
+ plan: result.plan,
915
+ counts: result.counts,
916
+ estCostUsd: result.estCostUsd,
917
+ meter,
918
+ persistence: {
919
+ runSaved: saved,
920
+ planSaved,
921
+ planId: planSaved ? result.plan.id : null,
922
+ hostedPlanUrl,
923
+ },
924
+ }, null, 2));
925
+ return;
926
+ }
927
+
928
+ if (verbosePlanRequested(args)) {
929
+ console.log(patchPlanToMarkdown(result.plan));
930
+ console.log(`\nAcquisition`);
931
+ console.log(` Leads ${result.counts.created} net-new · ${result.counts.unassigned} unassigned`);
932
+ console.log(` Est. cost $${result.estCostUsd.toFixed(2)}`);
933
+ console.log(` Budget ${gaugeLine ?? meterLine}`);
934
+ if (hostedPlanUrl) console.log(` Plan ${hostedPlanUrl}`);
935
+ } else if (planSaved || !saved) {
936
+ console.log(compactPlan(result.plan, { saved: planSaved }));
937
+ console.log(meterLine);
938
+ if (gaugeLine) console.log(gaugeLine);
939
+ } else {
940
+ console.log(meterLine);
941
+ if (gaugeLine) console.log(gaugeLine);
942
+ console.log("No net-new leads to create (everything sourced already matched, or was withheld by the meter).");
943
+ }
944
+
945
+ if (planSaved) {
946
+ console.log(`\nApprove fullstackgtm plans approve ${result.plan.id} --operations all`);
947
+ console.log(`Apply fullstackgtm apply --plan-id ${result.plan.id} --provider <hubspot|salesforce>`);
948
+ const hostedRuns = hostedRunsUrl();
949
+ if (hostedRuns) console.log(`Observe ${hostedRuns}`);
950
+ console.log("The meter is charged only when a create lands at apply.");
951
+ }
952
+ }
953
+
879
954
  function hostedRunsUrl(): string | null {
880
955
  const baseUrl = getCredential("broker")?.baseUrl?.replace(/\/+$/, "");
881
956
  return baseUrl ? `${baseUrl}/dashboard/runs` : null;
package/src/cli/help.ts CHANGED
@@ -20,13 +20,16 @@ Usage:
20
20
  fullstackgtm login salesforce --instance-url <url> [--no-validate] advanced: paste access token
21
21
  fullstackgtm login stripe [--no-validate]
22
22
  fullstackgtm login anthropic | openai store an LLM API key for call parse/score
23
- 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>
23
+ fullstackgtm login apollo | clay store a data-provider Public API key
24
+ fullstackgtm login pipe0 | explorium | theirstack store a discovery-provider key (theirstack = technographic TAM)
25
+ fullstackgtm login heyreach store a HeyReach key for enrich acquire --source linkedin
26
+ fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|heyreach|broker>
24
27
 
25
28
  Secrets (tokens, client secrets) are NEVER passed as flags — they leak via
26
29
  the process list and shell history. Pipe them on stdin or enter them at the
27
30
  interactive prompt:
28
31
  echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot --private-token
29
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
32
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
30
33
  cold start: scaffold icp.json + enrich.config.json + a
31
34
  PLAYBOOK wired for this workspace (the CLI ships primitives,
32
35
  your agent is the orchestrator — see docs/recipes.md)
@@ -76,7 +79,7 @@ Usage:
76
79
  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]
77
80
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
78
81
  fullstackgtm tam report [--name <n>] [--out <path>]
79
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
82
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
80
83
  size the reachable market FROM the ICP (a real account count ×
81
84
  ACV; buyers/account = the contact population target), then fill
82
85
  it: populate schedules plan-only enrich acquire --save (apply
@@ -298,7 +301,7 @@ export const HELP: Record<string, HelpEntry> = {
298
301
  summary: "scaffold a workspace (icp.json + enrich.config.json + PLAYBOOK)",
299
302
  phase: "Setup",
300
303
  synopsis: [
301
- "fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
304
+ "fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]",
302
305
  ],
303
306
  detail:
304
307
  "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).",
@@ -312,7 +315,7 @@ export const HELP: Record<string, HelpEntry> = {
312
315
  "fullstackgtm login hubspot | salesforce hosted browser OAuth (default)",
313
316
  "fullstackgtm login --via <hosted url> pair with a team deployment",
314
317
  "fullstackgtm login stripe",
315
- "fullstackgtm login anthropic | openai | apollo",
318
+ "fullstackgtm login anthropic | openai | apollo | clay",
316
319
  ],
317
320
  detail:
318
321
  "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`.",
@@ -321,7 +324,7 @@ export const HELP: Record<string, HelpEntry> = {
321
324
  logout: {
322
325
  summary: "remove stored credentials for a provider",
323
326
  phase: "Setup",
324
- synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|pipe0|explorium|broker>"],
327
+ synopsis: ["fullstackgtm logout <hubspot|salesforce|stripe|anthropic|openai|apollo|clay|pipe0|explorium|broker>"],
325
328
  seeAlso: ["login", "doctor"],
326
329
  },
327
330
  doctor: {
package/src/cli/init.ts CHANGED
@@ -16,7 +16,7 @@ import { option } from "./shared.ts";
16
16
  export function initCommand(args: string[]) {
17
17
  if (args.includes("--help") || args.includes("-h")) {
18
18
  console.log(`Usage:
19
- fullstackgtm init [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
19
+ fullstackgtm init [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--out <dir>] [--force]
20
20
 
21
21
  Scaffolds a workspace so the first acquire/signals/judge/draft commands work:
22
22
  icp.json starter ICP (edit, or rebuild with \`icp interview\`)
@@ -28,8 +28,8 @@ docs/recipes.md for the full play set. Existing files are kept unless --force.`)
28
28
  return;
29
29
  }
30
30
  const source = (option(args, "--source") ?? "pipe0") as InitSource;
31
- if (!["pipe0", "explorium", "linkedin"].includes(source)) {
32
- throw new Error(`init: --source must be pipe0|explorium|linkedin (got "${source}")`);
31
+ if (!["pipe0", "explorium", "clay", "linkedin"].includes(source)) {
32
+ throw new Error(`init: --source must be pipe0|explorium|clay|linkedin (got "${source}")`);
33
33
  }
34
34
  const provider = (option(args, "--provider") ?? "hubspot") as InitProvider;
35
35
  if (!["hubspot", "salesforce"].includes(provider)) {
package/src/cli/tam.ts CHANGED
@@ -6,6 +6,7 @@ import { getCredential } from "../credentials.ts";
6
6
  import { appendCoverage, computeCoverage, coverageCountsFromSnapshot, classifyCoverage, coverageToText, deriveAcvFromClosedWon, deriveBuyersPerAccount, estimateTam, loadTamModel, projectEta, readCoverageTimeline, saveTamModel, tamReportToMarkdown, type AcvBasis, type TamCrossCheck } from "../tam.ts";
7
7
  import { probeExploriumBusinessCount } from "../connectors/prospectSources.ts";
8
8
  import { theirStackCountCompanies, theirStackPullCost, theirStackSearchCompanies } from "../connectors/theirstack.ts";
9
+ import { clayApiKey } from "../connectors/clay.ts";
9
10
  import { icpToExploriumBusinessFilters, icpToTheirStackFilters } from "../icp.ts";
10
11
  import { scheduleCommand } from "./schedule.ts";
11
12
  import { loadIcp, numericOption, option, readSnapshot, saveRequested } from "./shared.ts";
@@ -14,7 +15,8 @@ import { unknownSubcommandError } from "./suggest.ts";
14
15
 
15
16
  /** Best-effort enrich-config load for apply-time acquire-budget enforcement. */
16
17
  /** Provider API key: env override first, then the credential store (`login`). */
17
- export function providerKey(provider: "explorium" | "pipe0" | "heyreach" | "theirstack"): string {
18
+ export function providerKey(provider: "explorium" | "pipe0" | "clay" | "heyreach" | "theirstack"): string {
19
+ if (provider === "clay") return clayApiKey();
18
20
  const envName =
19
21
  provider === "explorium"
20
22
  ? "EXPLORIUM_API_KEY"
@@ -44,7 +46,7 @@ export async function tamCommand(args: string[]) {
44
46
  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]
45
47
  fullstackgtm tam status [--name <n>] <source options> [--save] [--json]
46
48
  fullstackgtm tam report [--name <n>] [--out <path>]
47
- fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|linkedin] [--provider hubspot|salesforce] [--label <l>]
49
+ fullstackgtm tam populate [--name <n>] --cron "<expr>" [--source pipe0|explorium|clay|linkedin] [--provider hubspot|salesforce] [--label <l>]
48
50
 
49
51
  Estimate the reachable market FROM your ICP: a real account count × a confirmed
50
52
  ANNUAL ACV (--acv <annual-usd>, or --acv-from-crm --deal-period monthly|quarterly|
@@ -0,0 +1,155 @@
1
+ import { getCredential } from "../credentials.ts";
2
+ import { ProviderHttpError } from "../providerError.ts";
3
+ import type { Prospect } from "./prospectSources.ts";
4
+
5
+ export const CLAY_PUBLIC_API_BASE = "https://api.clay.com/public/v0";
6
+
7
+ export type ClayKeyValidation = {
8
+ ok: boolean;
9
+ detail: string;
10
+ workspaceId?: string;
11
+ };
12
+
13
+ export type ClaySearchSourceType = "people" | "companies";
14
+
15
+ export type ClayPeopleSearchPage = {
16
+ prospects: Prospect[];
17
+ hasMore: boolean;
18
+ };
19
+
20
+ function clayHeaders(apiKey: string): Record<string, string> {
21
+ return { "clay-api-key": apiKey, Accept: "application/json", "Content-Type": "application/json" };
22
+ }
23
+
24
+ /** Create a forward-only Clay search iterator. This call does not fetch a page. */
25
+ export async function createClaySearch(opts: {
26
+ apiKey: string;
27
+ sourceType: ClaySearchSourceType;
28
+ filters: Record<string, unknown>;
29
+ fetchImpl?: typeof fetch;
30
+ apiBaseUrl?: string;
31
+ }): Promise<string> {
32
+ const fetchImpl = opts.fetchImpl ?? fetch;
33
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
34
+ const response = await fetchImpl(`${base}/search/filters-mode`, {
35
+ method: "POST",
36
+ headers: clayHeaders(opts.apiKey),
37
+ body: JSON.stringify({ source_type: opts.sourceType, filters: opts.filters }),
38
+ });
39
+ if (!response.ok) throw new ProviderHttpError("Clay", "create search", response.status);
40
+ const body = await response.json() as { search_id?: unknown; searchId?: unknown };
41
+ const searchId = body.search_id ?? body.searchId;
42
+ if (typeof searchId !== "string" || !searchId) throw new Error("Clay create search returned no search_id.");
43
+ return searchId;
44
+ }
45
+
46
+ /** Consume the next people page from a Clay search iterator. */
47
+ export async function runClayPeopleSearchPage(opts: {
48
+ apiKey: string;
49
+ searchId: string;
50
+ limit?: number;
51
+ fetchImpl?: typeof fetch;
52
+ apiBaseUrl?: string;
53
+ }): Promise<ClayPeopleSearchPage> {
54
+ const fetchImpl = opts.fetchImpl ?? fetch;
55
+ const base = (opts.apiBaseUrl ?? CLAY_PUBLIC_API_BASE).replace(/\/$/, "");
56
+ const limit = Math.max(1, Math.min(opts.limit ?? 25, 100));
57
+ const response = await fetchImpl(`${base}/search/filters-mode/${encodeURIComponent(opts.searchId)}/run`, {
58
+ method: "POST",
59
+ headers: clayHeaders(opts.apiKey),
60
+ body: JSON.stringify({ limit }),
61
+ });
62
+ if (!response.ok) throw new ProviderHttpError("Clay", "run people search", response.status);
63
+ const body = await response.json() as { data?: unknown; has_more?: unknown; hasMore?: unknown };
64
+ if (!Array.isArray(body.data)) throw new Error("Clay people search returned an invalid data array.");
65
+ const hasMore = body.has_more ?? body.hasMore;
66
+ if (typeof hasMore !== "boolean") throw new Error("Clay people search returned no has_more flag.");
67
+ return { prospects: body.data.map(normalizeClayPerson), hasMore };
68
+ }
69
+
70
+ export function normalizeClayPerson(value: unknown): Prospect {
71
+ const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
72
+ const location = row.structured_location && typeof row.structured_location === "object"
73
+ ? row.structured_location as Record<string, unknown>
74
+ : {};
75
+ const fullName = stringValue(row.name);
76
+ return {
77
+ firstName: stringValue(row.first_name),
78
+ lastName: stringValue(row.last_name),
79
+ fullName,
80
+ jobTitle: stringValue(row.latest_experience_title),
81
+ companyName: stringValue(row.latest_experience_company),
82
+ companyDomain: bareDomain(stringValue(row.domain)),
83
+ linkedin: normalizeLinkedin(stringValue(row.url)),
84
+ headline: undefined,
85
+ sourceId: normalizeLinkedin(stringValue(row.url)) ?? fullName,
86
+ location: {
87
+ city: stringValue(location.city),
88
+ state: stringValue(location.state),
89
+ region: stringValue(location.region),
90
+ country: stringValue(location.country),
91
+ countryCode: stringValue(location.country_iso),
92
+ },
93
+ };
94
+ }
95
+
96
+ function stringValue(value: unknown): string | undefined {
97
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
98
+ }
99
+
100
+ function bareDomain(value: string | undefined): string | undefined {
101
+ return value?.replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/.*$/, "").toLowerCase() || undefined;
102
+ }
103
+
104
+ function normalizeLinkedin(value: string | undefined): string | undefined {
105
+ if (!value) return undefined;
106
+ const normalized = value.startsWith("http") ? value : `https://${value.replace(/^\/+/, "")}`;
107
+ return normalized.replace(/\/$/, "");
108
+ }
109
+
110
+ /** Validate a Clay Public API key without creating a search or spending enrichment credits. */
111
+ export async function validateClayApiKey(
112
+ apiKey: string,
113
+ fetchImpl: typeof fetch = fetch,
114
+ apiBaseUrl = CLAY_PUBLIC_API_BASE,
115
+ ): Promise<ClayKeyValidation> {
116
+ let response: Response;
117
+ try {
118
+ response = await fetchImpl(`${apiBaseUrl.replace(/\/$/, "")}/me`, {
119
+ headers: { "clay-api-key": apiKey, Accept: "application/json" },
120
+ });
121
+ } catch (error) {
122
+ const detail = error instanceof Error ? error.message : String(error);
123
+ return { ok: false, detail: `Cannot reach the Clay Public API: ${detail}` };
124
+ }
125
+ if (!response.ok) return { ok: false, detail: `Clay rejected the key: HTTP ${response.status}` };
126
+ let body: unknown;
127
+ try {
128
+ body = await response.json();
129
+ } catch {
130
+ return { ok: false, detail: "Clay accepted the request but returned an invalid JSON response." };
131
+ }
132
+ const record = body && typeof body === "object" ? body as Record<string, unknown> : {};
133
+ const workspace = record.workspace && typeof record.workspace === "object"
134
+ ? record.workspace as Record<string, unknown>
135
+ : undefined;
136
+ const rawWorkspaceId = record.workspace_id ?? record.workspaceId ?? workspace?.id;
137
+ const workspaceId = typeof rawWorkspaceId === "string" || typeof rawWorkspaceId === "number"
138
+ ? String(rawWorkspaceId)
139
+ : undefined;
140
+ return {
141
+ ok: true,
142
+ detail: workspaceId ? `Key accepted by Clay workspace ${workspaceId}.` : "Key accepted by the Clay Public API.",
143
+ workspaceId,
144
+ };
145
+ }
146
+
147
+ /** Resolve Clay credentials using the standard env -> profile-scoped store ladder. */
148
+ export function clayApiKey(env: Record<string, string | undefined> = process.env): string {
149
+ const key = env.CLAY_API_KEY ?? getCredential("clay")?.accessToken;
150
+ if (key) return key;
151
+ throw new Error(
152
+ 'No Clay credentials. Run `clay api-keys create --name "fullstackgtm"`, then pipe the key to ' +
153
+ '`fullstackgtm login clay`, or set CLAY_API_KEY.',
154
+ );
155
+ }
@@ -34,6 +34,12 @@ export type Prospect = {
34
34
  linkedin?: string;
35
35
  /** real work email once resolved (pipe0); never the hashed value */
36
36
  email?: string;
37
+ /** Validated mobile number when a contact provider explicitly returns one. */
38
+ mobile?: string;
39
+ /** Validated business direct dial when distinct from mobile. */
40
+ directDial?: string;
41
+ /** Person geography returned by identity-search providers. */
42
+ location?: { city?: string; state?: string; region?: string; country?: string; countryCode?: string };
37
43
  /** ICP fit score 0..1, set by the acquire scorer */
38
44
  fitScore?: number;
39
45
  /** provider-native id for traceability */
@@ -471,7 +477,7 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
471
477
  }
472
478
 
473
479
  // ---------------------------------------------------------------------------
474
- // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
480
+ // Pre-enrichment dedup: identity keys shared between prospects and CRM contacts, so
475
481
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
476
482
 
477
483
  function normName(value: string | undefined): string {