fullstackgtm 0.42.0 → 0.44.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 +273 -0
- package/README.md +18 -7
- package/dist/calls.d.ts +13 -0
- package/dist/calls.js +14 -3
- package/dist/cli.js +718 -60
- package/dist/connectors/hubspot.js +58 -24
- package/dist/connectors/outboxChannel.d.ts +64 -0
- package/dist/connectors/outboxChannel.js +170 -0
- package/dist/connectors/prospectSources.d.ts +22 -0
- package/dist/connectors/prospectSources.js +43 -0
- package/dist/connectors/salesforce.js +40 -15
- package/dist/connectors/signalSources.d.ts +105 -0
- package/dist/connectors/signalSources.js +316 -0
- package/dist/connectors/theirstack.d.ts +84 -0
- package/dist/connectors/theirstack.js +125 -0
- package/dist/draft.js +27 -5
- package/dist/enrich.d.ts +6 -0
- package/dist/enrich.js +47 -1
- package/dist/health.d.ts +12 -0
- package/dist/health.js +29 -2
- package/dist/icp.d.ts +47 -3
- package/dist/icp.js +105 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/init.d.ts +47 -0
- package/dist/init.js +143 -0
- package/dist/judge.d.ts +36 -3
- package/dist/judge.js +46 -10
- package/dist/judgeEval.d.ts +7 -0
- package/dist/judgeEval.js +8 -1
- package/dist/runReport.d.ts +26 -0
- package/dist/runReport.js +15 -0
- package/dist/schedule.js +18 -4
- package/dist/signals.d.ts +58 -0
- package/dist/signals.js +65 -0
- package/dist/tam.d.ts +225 -0
- package/dist/tam.js +470 -0
- package/dist/types.d.ts +12 -0
- package/docs/api.md +26 -4
- package/docs/outbox-format.md +92 -0
- package/docs/recipes.md +195 -0
- package/docs/roadmap-to-1.0.md +31 -1
- package/docs/signal-spool-format.md +162 -0
- package/docs/tam.md +195 -0
- package/llms.txt +89 -1
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +17 -1
- package/src/calls.ts +24 -3
- package/src/cli.ts +809 -55
- package/src/connectors/hubspot.ts +55 -23
- package/src/connectors/outboxChannel.ts +202 -0
- package/src/connectors/prospectSources.ts +57 -0
- package/src/connectors/salesforce.ts +39 -14
- package/src/connectors/signalSources.ts +363 -0
- package/src/connectors/theirstack.ts +170 -0
- package/src/draft.ts +26 -5
- package/src/enrich.ts +51 -1
- package/src/health.ts +47 -2
- package/src/icp.ts +113 -11
- package/src/index.ts +42 -0
- package/src/init.ts +166 -0
- package/src/judge.ts +85 -11
- package/src/judgeEval.ts +8 -1
- package/src/runReport.ts +39 -0
- package/src/schedule.ts +20 -4
- package/src/signals.ts +95 -0
- package/src/tam.ts +654 -0
- package/src/types.ts +12 -0
package/docs/recipes.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Recipes — composing the primitives into GTM plays
|
|
2
|
+
|
|
3
|
+
fullstackgtm ships **governed primitives**, not a workflow engine. The
|
|
4
|
+
orchestrator is **your coding agent**: it composes these verbs into the play the
|
|
5
|
+
operator wants, surfaces the one human approval gate, and bridges the last mile
|
|
6
|
+
to the sender. There is deliberately no `outbound` mega-command — that would
|
|
7
|
+
hard-code one play when the value is that you assemble the play you need.
|
|
8
|
+
|
|
9
|
+
Every verb emits stable `--json`, names the object type it operates on, and
|
|
10
|
+
surfaces the join it traversed (`accountId` / `domain` / `contactId`), so chains
|
|
11
|
+
compose without hand-gluing the contact↔account relationship. Every write goes
|
|
12
|
+
through the same `dry-run → plans approve → apply` gate. **The package never
|
|
13
|
+
sends** — `draft` produces an approved *task/opener*; the actual send happens in
|
|
14
|
+
the operator's channel tool (HeyReach for LinkedIn, an email tool for email),
|
|
15
|
+
which the agent drives with the operator's credentials.
|
|
16
|
+
|
|
17
|
+
The data spine: a **contact** belongs to an **account** (`contact.accountId`); an
|
|
18
|
+
account is keyed by its **domain**. `signals`/`icp judge` watch account domains;
|
|
19
|
+
`acquire` creates contacts *and* their domain-stamped accounts, so an acquired
|
|
20
|
+
lead is immediately watchable. Keep that join in mind when chaining.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Recipe 1 — Cold start: fill the CRM with targeted, owned, emailed leads
|
|
25
|
+
|
|
26
|
+
**Goal:** go from nothing to a CRM seeded with on-ICP contacts (and their
|
|
27
|
+
signal-watchable accounts).
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Connect (secrets via stdin/env, never argv)
|
|
31
|
+
echo "$HUBSPOT_TOKEN" | fullstackgtm login hubspot
|
|
32
|
+
echo "$PIPE0_KEY" | fullstackgtm login pipe0 # work-email + company-domain resolution
|
|
33
|
+
echo "$EXPLORIUM_KEY" | fullstackgtm login explorium # net-new discovery (optional)
|
|
34
|
+
|
|
35
|
+
# 2. Build the ICP (the agent drives the interview — the CLI can't call AskUserQuestion itself)
|
|
36
|
+
fullstackgtm icp interview # emits INTERVIEW_SPEC; agent asks the questions
|
|
37
|
+
fullstackgtm icp set answers.json # writes ./icp.json
|
|
38
|
+
fullstackgtm icp show # renders the ICP + the per-provider discovery filters
|
|
39
|
+
|
|
40
|
+
# 3. Acquire — dry-run first (writes NOTHING), then approve + apply
|
|
41
|
+
fullstackgtm enrich acquire --source pipe0 --provider hubspot --json # scored, deduped, metered create_record plan
|
|
42
|
+
fullstackgtm enrich acquire --source pipe0 --provider hubspot --save # persist as needs_approval
|
|
43
|
+
fullstackgtm plans approve <plan-id> --operations all
|
|
44
|
+
fullstackgtm apply --plan-id <plan-id> --provider hubspot
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**What lands:** net-new contacts, owner-stamped (never born ownerless), each
|
|
48
|
+
linked to a **domain-stamped account** (so `signals`/`judge` can watch it). The
|
|
49
|
+
run is **metered** (records + spend, per profile) and **resolve-first** (never
|
|
50
|
+
creates over a possible dup). The dry-run op reason names the account it will
|
|
51
|
+
create/link — review it before `--save`.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Recipe 2 — Trigger-based outbound loop (the core play)
|
|
56
|
+
|
|
57
|
+
**Goal:** reach an account the week something changes, with a grounded opener
|
|
58
|
+
that lands on a real contact.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# 1. Detect — fresh buying triggers (free ATS boards in the box; ingest for funding/social)
|
|
62
|
+
fullstackgtm signals fetch --bucket job,funding --watchlist crm:<segment> --save
|
|
63
|
+
|
|
64
|
+
# 2. Judge — rank into send/nurture/skip. PASS THE SNAPSHOT so each decision
|
|
65
|
+
# resolves its CRM target (accountId + the contact[s] to reach).
|
|
66
|
+
fullstackgtm icp judge --signals-from latest --provider hubspot --save --json
|
|
67
|
+
# → each decision carries: accountDomain, accountId, contact {id,email,title},
|
|
68
|
+
# contacts[] (all in-CRM contacts at the account — for multi-threading)
|
|
69
|
+
|
|
70
|
+
# 3. Draft — one trigger-grounded opener per hot account, as a governed create_task
|
|
71
|
+
fullstackgtm draft --from-judge latest --channel email --save --json
|
|
72
|
+
# → the task targets the resolved contact.id (or accountId). A domain-only
|
|
73
|
+
# decision (account not in the CRM yet) is REJECTED with "acquire it first"
|
|
74
|
+
# — run Recipe 1 for that account, then re-judge with the snapshot.
|
|
75
|
+
|
|
76
|
+
# 4. Approve + apply (the ONE human gate)
|
|
77
|
+
fullstackgtm plans approve <plan-id> --operations all
|
|
78
|
+
fullstackgtm apply --plan-id <plan-id> --provider hubspot
|
|
79
|
+
|
|
80
|
+
# 5. Send — OUTSIDE the package. The agent pushes the approved opener to the
|
|
81
|
+
# operator's sender (e.g. HeyReach for LinkedIn, an email tool for email)
|
|
82
|
+
# using the operator's credentials. The package never sends.
|
|
83
|
+
|
|
84
|
+
# 6. Record the outcome — credit the contact you reached, so weights re-learn
|
|
85
|
+
fullstackgtm signals outcome --account <domain> --contact <contactId> --result replied
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Why the snapshot in step 2 matters:** without it, decisions are domain-only and
|
|
89
|
+
`draft` cannot target a real record — it will reject them. With it, the loop is
|
|
90
|
+
contact-coherent end to end. `--with-history` additionally enables the memory
|
|
91
|
+
(don't re-touch a recently-touched account) and fit-scoring inputs.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Recipe 3 — Continuous: schedule the detect/plan side, approve daily
|
|
96
|
+
|
|
97
|
+
**Goal:** run steps 1–3 of Recipe 2 on a cadence; keep the write gated.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
fullstackgtm schedule add "signals fetch --bucket job,funding --watchlist crm:<segment> --save" --cron "0 7 * * 1-5"
|
|
101
|
+
fullstackgtm schedule add "icp judge --signals-from latest --provider hubspot --save" --cron "15 7 * * 1-5"
|
|
102
|
+
fullstackgtm schedule add "draft --from-judge latest --save" --cron "30 7 * * 1-5"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`schedule` is **read/plan-side only and NEVER auto-approves** — each morning a
|
|
106
|
+
queue of `needs_approval` plans is waiting; the agent presents them, the operator
|
|
107
|
+
approves, and `apply` runs (only `apply --plan-id <id>` is schedulable, and the
|
|
108
|
+
plan's `approved` status is re-checked at every firing).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Recipe 4 — ABM: bring your target accounts, watch them, draft when they move
|
|
113
|
+
|
|
114
|
+
**Goal:** start from a list of target *companies* (not people).
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. Acquire the ACCOUNTS (acquire is keyed by object type — company rows → account create_record)
|
|
118
|
+
fullstackgtm enrich ingest target-companies.csv --source clay --objects companies
|
|
119
|
+
fullstackgtm enrich acquire --source clay --provider hubspot --save # create.company → accounts WITH domains
|
|
120
|
+
fullstackgtm plans approve <id> --operations all && fullstackgtm apply --plan-id <id> --provider hubspot
|
|
121
|
+
|
|
122
|
+
# 2. From here it's Recipe 2 — the accounts now carry domains, so signals/judge watch them,
|
|
123
|
+
# and (optionally) Recipe 1's acquire fills in contacts at each.
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The `acquire.create.company` mapping (match key `domain`, properties →
|
|
127
|
+
`name`/`domain`) makes each company a **signal-watchable account**. See
|
|
128
|
+
[api.md → Acquire](./api.md).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Recipe 5 — Hygiene-gate the outbound (don't outbound into a dirty CRM)
|
|
133
|
+
|
|
134
|
+
**Goal:** clean first, so signals/judge reason over correct ownership and
|
|
135
|
+
de-duplicated accounts; measure the lift.
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
fullstackgtm health --json # per-object-type score + trend (read-only)
|
|
139
|
+
fullstackgtm audit --provider hubspot --save # → plan id
|
|
140
|
+
fullstackgtm dedupe account --key domain --save # collapse duplicate accounts (signals key on domain)
|
|
141
|
+
fullstackgtm reassign --assign-unowned --to <ownerId> --save # no ownerless leads
|
|
142
|
+
# approve + apply each, then run Recipe 2. Re-check `health` after to attribute the lift.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`health --json.byObjectType` tells you *which* object type is messy (clean
|
|
146
|
+
contacts but a messy pipeline read differently). Clean the accounts before
|
|
147
|
+
outbound — `signals`/`judge` key on account domain, so duplicate/owner-less
|
|
148
|
+
accounts distort the queue.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Recipe 6 — Size your TAM, then fill it on a schedule
|
|
153
|
+
|
|
154
|
+
**Goal:** turn the ICP into a defensible market size, then close the gap to it
|
|
155
|
+
over weeks — with a number for "how far along am I."
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# 1. Estimate the universe FROM the ICP. Either probe a provider for the
|
|
159
|
+
# ICP-match count, or pass --accounts from a known segment. Record a baseline.
|
|
160
|
+
fullstackgtm tam estimate --source explorium --provider hubspot --acv 180000
|
|
161
|
+
# → accounts: matching COMPANIES (Explorium /v1/businesses) = the account universe.
|
|
162
|
+
# → ACV: a real ANNUAL figure you confirm (--acv), or --acv-from-crm
|
|
163
|
+
# --deal-period monthly|quarterly|annual to derive+annualize from closed-won.
|
|
164
|
+
# (--provider is your coverage source, not your ACV — it won't auto-set it.)
|
|
165
|
+
# → buyers/account: CRM avg-contacts/account (or --buyers-per-account); labeled.
|
|
166
|
+
# → TAM model: accounts × ACV = $; contacts = accounts × buyers (population target)
|
|
167
|
+
|
|
168
|
+
# 2. Schedule the population — plan-only acquire, apply stays a human gate.
|
|
169
|
+
fullstackgtm tam populate --cron "0 7 * * 1-5" --source pipe0 --provider hubspot
|
|
170
|
+
fullstackgtm schedule install
|
|
171
|
+
|
|
172
|
+
# 3. Each cycle: approve the morning's lead plan (Recipe 1's tail), then apply.
|
|
173
|
+
fullstackgtm plans list && fullstackgtm plans approve <id> --operations all
|
|
174
|
+
fullstackgtm apply --plan-id <id> --provider hubspot
|
|
175
|
+
|
|
176
|
+
# 4. Track where you're getting to — stamp a coverage reading; once a couple
|
|
177
|
+
# accumulate, report projects a burn-up + ETA to full coverage.
|
|
178
|
+
fullstackgtm tam status --provider hubspot --save
|
|
179
|
+
fullstackgtm tam report > tam-report.md
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The estimate is honest about its source (provider count vs. assumption), the
|
|
183
|
+
population never auto-writes (the meter is charged only at apply), and the ETA is
|
|
184
|
+
refused rather than faked until the timeline shows real movement. See
|
|
185
|
+
[tam.md](./tam.md).
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## The boundary, restated
|
|
190
|
+
|
|
191
|
+
- **Read freely. Write only through `plans approve → apply`.** Never bypass it.
|
|
192
|
+
- **The package never sends.** `draft` is the last governed step; the send is the
|
|
193
|
+
agent calling the operator's channel tool with the operator's credentials.
|
|
194
|
+
- **You (the agent) are the orchestrator.** These recipes are starting points —
|
|
195
|
+
compose, branch, and loop them into the play the operator actually wants.
|
package/docs/roadmap-to-1.0.md
CHANGED
|
@@ -140,8 +140,38 @@ outward, one layer per release, each consolidating before the next expanded:
|
|
|
140
140
|
broker-https enforcement, the build/CI dist-integrity gate (published dist is
|
|
141
141
|
provably from source), Salesforce-merge capability matrix.
|
|
142
142
|
|
|
143
|
+
## 0.29 → 0.43 — from hygiene to the full outbound loop, as shipped
|
|
144
|
+
|
|
145
|
+
The next train extended the governed spine from *fixing the CRM* to *filling
|
|
146
|
+
it and acting on it*, without ever loosening the write gate:
|
|
147
|
+
|
|
148
|
+
- **0.29–0.34** — market-map maturation (config suggestion + taxonomy types,
|
|
149
|
+
sourcing helpers, competitor discovery, vendor logos/scale in the report)
|
|
150
|
+
plus call-type classification with type-specific coaching rubrics (0.32);
|
|
151
|
+
Salesforce `dedupe`/`merge_records` via SOAP and pipeline-aware closed/won
|
|
152
|
+
detection (0.30).
|
|
153
|
+
- **0.35–0.36** — DX cohesion: progressive-disclosure help, summary-first
|
|
154
|
+
`audit` with next-step guidance; the engagement workspace — a deterministic
|
|
155
|
+
`health` verb over a per-profile timeline.
|
|
156
|
+
- **0.37–0.39** — acquire: `enrich acquire` (net-new, ICP-targeted,
|
|
157
|
+
resolve-first deduped, metered lead creation), lead assignment policies so
|
|
158
|
+
leads are never born ownerless, the LinkedIn (HeyReach) discovery source
|
|
159
|
+
with email resolution, and run observability for broker-paired CLIs.
|
|
160
|
+
- **0.40** — Salesforce reaches full write parity with HubSpot
|
|
161
|
+
(`create_record`); HubSpot date-field writes no longer false-conflict.
|
|
162
|
+
- **0.41** — signal-based outbound: `signals fetch/list/outcome/weights`,
|
|
163
|
+
`icp judge`/`icp eval` (timing × fit × memory, golden-set calibration
|
|
164
|
+
gate), `draft` (trigger-grounded openers as governed plans), and the LLM
|
|
165
|
+
base-URL override.
|
|
166
|
+
- **0.42** — bulk apply for lead creation (HubSpot batch + Salesforce
|
|
167
|
+
composite).
|
|
168
|
+
- **0.43** — the on-ramp and the seams: `init` workspace scaffolding,
|
|
169
|
+
`docs/recipes.md` (five composable plays), and the contact/account seam
|
|
170
|
+
campaign — contact-granular outbound end to end, account-level acquire for
|
|
171
|
+
ABM, `health` by object type.
|
|
172
|
+
|
|
143
173
|
The known-gaps list below predates these layers and has been re-verified
|
|
144
|
-
against the 0.
|
|
174
|
+
against the 0.43 surface (2026-07): still accurate, still open.
|
|
145
175
|
|
|
146
176
|
## Known real-portal gaps to close before 1.0
|
|
147
177
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Signal spool format (webhook landing zone)
|
|
2
|
+
|
|
3
|
+
The **spool** is how push-based signal platforms — website de-anonymization
|
|
4
|
+
(RB2B), social/job listening (Trigify), form-submission webhooks (HubSpot) —
|
|
5
|
+
reach `fullstackgtm`. The CLI is a deterministic batch tool, not an always-on
|
|
6
|
+
server, so it does not receive webhooks itself. Instead:
|
|
7
|
+
|
|
8
|
+
1. A **receiver** you run (any HTTP endpoint — 20 lines, or an off-the-shelf
|
|
9
|
+
webhook-to-file tool) appends one JSON row per event to a spool file.
|
|
10
|
+
2. `fullstackgtm signals fetch --connector file` reads the spool on its next run,
|
|
11
|
+
turning each row into a ranked signal through the same evidence-gate → dedup →
|
|
12
|
+
weight pipeline as every other source.
|
|
13
|
+
|
|
14
|
+
The spool format and the `file` connector that reads it are open source. The
|
|
15
|
+
package ships **no receiver** — operating an always-on endpoint is out of scope
|
|
16
|
+
for the CLI (a managed receiver is part of the hosted product). This doc is the
|
|
17
|
+
contract your receiver writes to.
|
|
18
|
+
|
|
19
|
+
## The format
|
|
20
|
+
|
|
21
|
+
A spool file is **newline-delimited JSON (JSONL)**: one JSON object per line,
|
|
22
|
+
append-only. (A single `.json` file containing a JSON array is also accepted, for
|
|
23
|
+
convenience when staging by hand.) Each object is a **staged signal row**:
|
|
24
|
+
|
|
25
|
+
| Field | Required | Meaning |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `bucket` | yes¹ | One of `demand`, `funding`, `job`, `company`, `social`. |
|
|
28
|
+
| `accountDomain` | yes | The company domain. `domain` is accepted as an alias. Normalized (protocol/`www`/path stripped, lowercased) on read. |
|
|
29
|
+
| `trigger` | yes | Short human label, e.g. `"visited pricing page"`, `"raised Series B"`. |
|
|
30
|
+
| `quote` | yes | **Verbatim evidence** — the exact text a human can verify (a headline, a page title, the submitted form + email). A row with no quote is **dropped**, never faked. |
|
|
31
|
+
| `sourceUrl` | no | Link to the evidence. |
|
|
32
|
+
| `firstSeen` | no | ISO 8601 timestamp. Defaults to the fetch time. |
|
|
33
|
+
| `weight` | no | Explicit weight; defaults to the bucket's configured weight. |
|
|
34
|
+
|
|
35
|
+
¹ `bucket` may be omitted in a spool file — the `file` connector fills its
|
|
36
|
+
default (`company`). Set it explicitly per row when a source spans buckets.
|
|
37
|
+
|
|
38
|
+
Example spool file (`rb2b.jsonl`):
|
|
39
|
+
|
|
40
|
+
```jsonl
|
|
41
|
+
{"bucket":"company","accountDomain":"globex.com","trigger":"visited pricing 3x","quote":"globex.com — viewed /pricing and /demo, 3 sessions","sourceUrl":"https://app.rb2b.com/v/abc123"}
|
|
42
|
+
{"bucket":"company","accountDomain":"initech.com","trigger":"visited docs","quote":"initech.com — viewed /docs/api","sourceUrl":"https://app.rb2b.com/v/def456"}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A row that fails validation (unknown bucket, missing domain/trigger/quote) makes
|
|
46
|
+
the fetch error and names the offending row — a corrupt spool is surfaced, not
|
|
47
|
+
silently skipped. Keep your receiver's mapping faithful to this table.
|
|
48
|
+
|
|
49
|
+
## Where the spool lives
|
|
50
|
+
|
|
51
|
+
`--connector file` with **no path** reads the conventional landing zone:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
<profile home>/signals/spool/ (default: ~/.fullstackgtm/signals/spool/)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every `*.jsonl` (and `*.json`) file in that directory is read and concatenated,
|
|
58
|
+
name-sorted — so each platform can append to its own file and they all land in
|
|
59
|
+
one fetch:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
~/.fullstackgtm/signals/spool/rb2b.jsonl
|
|
63
|
+
~/.fullstackgtm/signals/spool/trigify.jsonl
|
|
64
|
+
~/.fullstackgtm/signals/spool/hubspot.jsonl
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Point at a different file or directory with `--connector-opt path=<path>`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# the conventional landing zone (zero-arg)
|
|
71
|
+
fullstackgtm signals fetch --connector file --save
|
|
72
|
+
|
|
73
|
+
# a specific file or directory
|
|
74
|
+
fullstackgtm signals fetch --connector file --connector-opt path=./events.jsonl --save
|
|
75
|
+
fullstackgtm signals fetch --connector file --connector-opt path=/var/spool/fsgtm --save
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The CLI **never writes** to the spool — your receiver does. The directory is
|
|
79
|
+
profile-scoped, so spools for different client workspaces stay separate.
|
|
80
|
+
|
|
81
|
+
### Re-reads and retention
|
|
82
|
+
|
|
83
|
+
Re-reading the same spool is safe: signals dedup on `(accountDomain, bucket,
|
|
84
|
+
trigger)` within the configured window (`dedupWindowDays`, default 30), so a row
|
|
85
|
+
that's still in the spool on the next fetch is deduped, not double-counted. The
|
|
86
|
+
CLI does not consume or truncate the spool — **retention is yours**: rotate or
|
|
87
|
+
truncate the files on your own schedule (e.g. nightly, or after a successful
|
|
88
|
+
`--save`). Unbounded spool growth is a self-host concern the CLI doesn't manage.
|
|
89
|
+
|
|
90
|
+
## Writing a receiver
|
|
91
|
+
|
|
92
|
+
A receiver is whatever turns a platform's webhook POST into spool rows. The
|
|
93
|
+
minimum: verify the platform's signature, map the payload to the fields above,
|
|
94
|
+
append one JSON line. Pseudocode:
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
// POST handler for your platform's webhook
|
|
98
|
+
function onWebhook(req) {
|
|
99
|
+
verifySignature(req); // platform's HMAC / shared secret
|
|
100
|
+
const row = mapToStagedRow(req.body); // per-platform mapping (below)
|
|
101
|
+
if (!row.quote) return 204; // no verifiable evidence -> skip
|
|
102
|
+
appendLine("~/.fullstackgtm/signals/spool/<platform>.jsonl", JSON.stringify(row));
|
|
103
|
+
return 204;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Keep the receiver dumb: no ranking, no CRM writes, no judgment. All of that is
|
|
108
|
+
the CLI's job, gated and deterministic, on the next `signals fetch`.
|
|
109
|
+
|
|
110
|
+
## Per-platform mapping
|
|
111
|
+
|
|
112
|
+
These map each platform's webhook payload to a staged row. Payload shapes drift —
|
|
113
|
+
verify against the platform's current webhook docs before relying on a field.
|
|
114
|
+
|
|
115
|
+
### RB2B (website visitor de-anonymization)
|
|
116
|
+
|
|
117
|
+
RB2B delivers an identified visitor (person + company) via outbound webhook.
|
|
118
|
+
Use the **company domain** as `accountDomain` and the visited pages as the
|
|
119
|
+
verbatim `quote`.
|
|
120
|
+
|
|
121
|
+
| Spool field | From RB2B payload |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `bucket` | `"company"` (or `"demand"` for high-intent pages) |
|
|
124
|
+
| `accountDomain` | the visitor's company domain |
|
|
125
|
+
| `trigger` | e.g. `"visited pricing"` derived from the top page |
|
|
126
|
+
| `quote` | `"<domain> — <pages viewed>"` (verbatim page paths) |
|
|
127
|
+
| `sourceUrl` | the RB2B visitor permalink |
|
|
128
|
+
|
|
129
|
+
### Trigify (social / job-change listening)
|
|
130
|
+
|
|
131
|
+
Trigify pushes social and job-change events. Map the monitored account's domain;
|
|
132
|
+
the post/job text is the evidence.
|
|
133
|
+
|
|
134
|
+
| Spool field | From Trigify payload |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `bucket` | `"social"` or `"job"` |
|
|
137
|
+
| `accountDomain` | the account/company domain |
|
|
138
|
+
| `trigger` | e.g. `"new role: VP Sales"` or `"posted about <topic>"` |
|
|
139
|
+
| `quote` | the verbatim post text or job title + snippet |
|
|
140
|
+
| `sourceUrl` | the source post / listing URL |
|
|
141
|
+
|
|
142
|
+
### HubSpot form-submission webhook
|
|
143
|
+
|
|
144
|
+
The `hubspot-forms` **pull** connector already covers recent submissions via the
|
|
145
|
+
Forms API (Phase 1). For real-time, subscribe to HubSpot's form-submission
|
|
146
|
+
webhook and append a `demand` row per submission — the same shape the pull
|
|
147
|
+
connector produces, so both paths dedup against each other.
|
|
148
|
+
|
|
149
|
+
| Spool field | From HubSpot submission |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `bucket` | `"demand"` |
|
|
152
|
+
| `accountDomain` | corporate domain of the submitted email (drop free-mail) |
|
|
153
|
+
| `trigger` | `"form: <form name>"` |
|
|
154
|
+
| `quote` | `"Submitted \"<form name>\" — <email>"` |
|
|
155
|
+
| `firstSeen` | submission timestamp (ISO 8601) |
|
|
156
|
+
|
|
157
|
+
## See also
|
|
158
|
+
|
|
159
|
+
- `docs/api.md` — the `signals` verb and the `SignalSourceConnector` contract.
|
|
160
|
+
- The connector taxonomy and the hosted/open-source split for the receiver are
|
|
161
|
+
described in the maintainer design doc (monorepo
|
|
162
|
+
`docs/spec-connectors-signals-outbound.md`).
|
package/docs/tam.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# TAM mapping — size the reachable market, then fill it
|
|
2
|
+
|
|
3
|
+
`tam` answers a longer-horizon question than `market` (the competitive map): **how
|
|
4
|
+
big is the reachable market for my ICP, and how far along am I in actually putting
|
|
5
|
+
it in the CRM?** It is deliberately *not* a one-shot "$10B TAM" headline — it pairs
|
|
6
|
+
a defensible estimate with an iterative, governed population loop and a coverage
|
|
7
|
+
timeline so "this will take a while" becomes a quantified ETA.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
tam estimate → tam populate → (approve/apply each cycle) → tam status --save → tam report
|
|
11
|
+
model schedule governed acquire coverage point burn-up + ETA
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The model
|
|
15
|
+
|
|
16
|
+
`tam estimate` derives the universe **from your ICP** (`icp.json`):
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
universe accounts = a real count for the ICP filter
|
|
20
|
+
contacts (population target) = accounts × buyers/account
|
|
21
|
+
TAM $ = accounts × ACV (account/per-logo basis — the default)
|
|
22
|
+
= contacts × ACV (buyer/per-seat basis, --acv-basis buyer)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The account count comes from one of three places, always **labeled** in the model:
|
|
26
|
+
|
|
27
|
+
- `--source theirstack` — **the real, technographically-targeted universe** (and the
|
|
28
|
+
only one that returns an actual list). TheirStack counts companies that *use* a
|
|
29
|
+
CRM/MAP — set `firmographics.technologies` (e.g. `["salesforce","hubspot",
|
|
30
|
+
"pipedrive"]`) — plus geo + employee bounds. `accountsSource:
|
|
31
|
+
"provider:theirstack (uses-CRM)"`. This is the buying signal for a CRM-hygiene/
|
|
32
|
+
RevOps tool: "company runs a CRM", not "company is a software publisher". **And
|
|
33
|
+
`tam accounts --source theirstack` pulls the actual company list** (real names +
|
|
34
|
+
domains) you can review, save (`--out <csv>`), and acquire into the CRM.
|
|
35
|
+
(Counting is cheap but **not free** — TheirStack rejects `limit:0`, so a count
|
|
36
|
+
returns 1 row ≈ 3 credits; a list pull is ~3 credits/company. `login theirstack`.)
|
|
37
|
+
- `--source explorium` — a firmographic **company count** from Explorium
|
|
38
|
+
`/v1/businesses` (geo + employee bands + NAICS). A count only — **Explorium 403s
|
|
39
|
+
when you try to pull the list**, and it can't target by CRM-usage, so it's a rough
|
|
40
|
+
size estimate, not a list. `accountsSource: "provider:explorium"`.
|
|
41
|
+
- `--accounts <n>` — an explicit assumption (e.g. a number from a provider export).
|
|
42
|
+
`accountsSource: "assumption"`.
|
|
43
|
+
|
|
44
|
+
> **Why technographic, and why a count isn't enough** (verified live, 2026-06-25/26):
|
|
45
|
+
> lead/people endpoints don't report a real universe — Explorium `/v1/prospects`
|
|
46
|
+
> `total_results` equals the requested page size (1→1, 10→10), and pipe0/Crustdata
|
|
47
|
+
> returns only a pagination cursor with no total. Explorium `/v1/businesses` counts
|
|
48
|
+
> companies (e.g. US mid-market software ≈ 19,000) — **as long as you don't send a
|
|
49
|
+
> `size` field, which silently caps the total** — but it can't filter by CRM-usage
|
|
50
|
+
> and **403s when you try to pull the list**. TheirStack is the technographic
|
|
51
|
+
> source that does both (target by tech, return real records). pipe0 stays a
|
|
52
|
+
> discovery/population source (`tam populate`), not a counting one; `--source pipe0`
|
|
53
|
+
> for `estimate` is rejected with that explanation.
|
|
54
|
+
|
|
55
|
+
Explorium caps `total_results` at **60,000** (a `provider:explorium (≥60k cap —
|
|
56
|
+
floor)` label + a ⚠). TheirStack has no such cap but list-pull costs credits, so
|
|
57
|
+
`tam accounts --max <n>` pages it.
|
|
58
|
+
|
|
59
|
+
### List-pull cost (never spend by surprise)
|
|
60
|
+
|
|
61
|
+
`tam accounts` pulls real records at **~3 TheirStack credits/company**, so it always
|
|
62
|
+
prints the cost up front and guards big pulls:
|
|
63
|
+
|
|
64
|
+
- `tam accounts --dry-run [--usd-per-credit <rate>]` — prices the pull AND the full
|
|
65
|
+
TAM (e.g. *"up to 100 companies ≈ 300 credits. Full TAM (~43,517) ≈ 130,551
|
|
66
|
+
credits (~$5,483) to materialize."*) and spends **0 credits**. The dollar figure
|
|
67
|
+
appears only when you give a rate — TheirStack pricing is ~$0.04–$0.11/credit by
|
|
68
|
+
tier, and the tool won't fabricate a default.
|
|
69
|
+
- A pull above `--max-credits` (default **150** ≈ 50 companies) requires `--confirm`
|
|
70
|
+
— so a large pull is always a deliberate choice, not an accident.
|
|
71
|
+
|
|
72
|
+
So the natural pattern is **count freely, pull in confirmed batches** (`--max N`),
|
|
73
|
+
acquire, measure coverage, repeat — rather than one large up-front spend.
|
|
74
|
+
|
|
75
|
+
**ACV must be a real, ANNUAL figure that you confirm** — never a band default, and
|
|
76
|
+
never silently lifted from the CRM. `--provider` is your **coverage** source, not
|
|
77
|
+
your ACV: the account universe is Explorium, coverage is the CRM, and the TAM's
|
|
78
|
+
economics are yours to confirm. Two confirmed paths, always labeled (`acv.source`):
|
|
79
|
+
|
|
80
|
+
- `--acv <annual-usd>` — your confirmed annualized ACV (`source: "explicit (annual)"`).
|
|
81
|
+
- `--acv-from-crm --deal-period monthly|quarterly|annual` — derive the **median
|
|
82
|
+
closed-won deal amount** and **annualize** it by the period you specify
|
|
83
|
+
(`source: "crm:closed-won (N deals, median $X/monthly ×12 = annual)"`). The period
|
|
84
|
+
is **required** because a deal amount can be MRR, quarterly, or annual — guessing
|
|
85
|
+
it is a 4–12× error (a $15k/mo deal is a $180k ACV). The deal count is in the
|
|
86
|
+
label, so a thin signal (e.g. "1 deal") is visible — don't over-trust it.
|
|
87
|
+
|
|
88
|
+
If you give neither, `estimate` errors rather than invent a number — and a bare
|
|
89
|
+
`--provider` does NOT auto-set ACV.
|
|
90
|
+
|
|
91
|
+
**Buyers/account** (the contact population target = accounts × buyers) gets the same
|
|
92
|
+
treatment: `--buyers-per-account <n>`, else the CRM's **average contacts per account**
|
|
93
|
+
(`crm:avg-contacts/account (N)`), else a clearly-labeled minimal assumption of 1
|
|
94
|
+
(`assumption:1-buyer`) — never a silent guess. `--acv-basis buyer` makes ACV per-seat
|
|
95
|
+
(TAM = contacts × ACV) instead of per-logo.
|
|
96
|
+
|
|
97
|
+
Optional citable **cross-checks** (`--cross-checks <file.json>`, an array of
|
|
98
|
+
`{claim, valueUsd?, sourceUrl, quote, asOf?}`) sit alongside the bottom-up number as a
|
|
99
|
+
top-down sanity check — the market-research flavor, evidence-attached.
|
|
100
|
+
|
|
101
|
+
`estimate` also records a **coverage baseline** (the CRM account/contact counts right
|
|
102
|
+
now, when a `--provider`/`--input`/`--demo` source is given) so `status` can attribute
|
|
103
|
+
what *this campaign* added versus what was already there.
|
|
104
|
+
|
|
105
|
+
## Populate it (governed, never auto-writes)
|
|
106
|
+
|
|
107
|
+
`tam populate --cron "<expr>"` wires a recurring **`enrich acquire --source <s> --save`**
|
|
108
|
+
into the scheduler. Each firing queues a `needs_approval` lead plan — it does **not**
|
|
109
|
+
write to the CRM, and the acquire meter is charged only at apply. Apply stays a separate
|
|
110
|
+
human gate (`apply --plan-id <id>`, re-checked approved at every firing). So a scheduled
|
|
111
|
+
TAM populates by *accumulating proposals you approve*, never by surprise leads.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
fullstackgtm tam populate --cron "0 7 * * 1-5" --source pipe0 --provider hubspot
|
|
115
|
+
fullstackgtm schedule install # activate the crontab entry
|
|
116
|
+
# each cycle:
|
|
117
|
+
fullstackgtm plans list # the morning's lead plan
|
|
118
|
+
fullstackgtm plans approve <id> --operations all
|
|
119
|
+
fullstackgtm apply --plan-id <id> --provider hubspot
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This is the one place the schedule allowlist permits a create-side verb, and only in
|
|
123
|
+
its plan-producing `--save` form (scheduling `enrich acquire` without `--save` is
|
|
124
|
+
rejected — it would queue nothing).
|
|
125
|
+
|
|
126
|
+
## Track coverage (where you're getting to)
|
|
127
|
+
|
|
128
|
+
Coverage is measured on the **intersection** of your CRM and the TAM's ICP — not a
|
|
129
|
+
raw account count. `tam estimate` stores the ICP filter on the model, and `tam
|
|
130
|
+
status` classifies every domain-bearing CRM account against it:
|
|
131
|
+
|
|
132
|
+
- **in-TAM** — matches every CRM-checkable criterion (size, industry) with no
|
|
133
|
+
contradiction. *Only these count toward coverage.*
|
|
134
|
+
- **out-of-TAM** — contradicts a criterion (wrong size/industry). Reported
|
|
135
|
+
separately; never inflates coverage. So 50k accounts loaded from other sources
|
|
136
|
+
that aren't your target market don't fake a high number.
|
|
137
|
+
- **unknown** — the record lacks the fields to judge. Surfaced, never silently
|
|
138
|
+
counted — it tells you what to enrich.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
fullstackgtm tam status --provider hubspot --save # classified coverage + a timeline reading
|
|
142
|
+
fullstackgtm tam report # markdown: model + in/out/unknown + burn-up + ETA
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
> **Honest limit:** geo and "uses a CRM" aren't on a CRM account record, so an
|
|
146
|
+
> in-TAM classification means "matches what the CRM lets us check" (size + industry),
|
|
147
|
+
> not a full technographic match. A `--reverify` pass (re-confirm geo/tech per
|
|
148
|
+
> domain via TheirStack) is planned; until then, status labels what it checked.
|
|
149
|
+
|
|
150
|
+
**Bottom-up vs top-down — squaring "more in CRM than estimated."** The estimate is a
|
|
151
|
+
*lower-bound prior*, not a ceiling — TheirStack only knows ~740k US firms, so your
|
|
152
|
+
multi-source CRM can legitimately hold more valid in-TAM accounts than it predicted.
|
|
153
|
+
Every in-TAM account is a *proven* member of the real market, so the in-TAM count is
|
|
154
|
+
the strongest floor on the true universe. When `in-TAM ≥ estimate`, `status` stops
|
|
155
|
+
reporting a fake "100%" and flips the signal: **"the estimate was a floor — your real
|
|
156
|
+
market is at least N; re-estimate (broader ICP / better source) to find the
|
|
157
|
+
headroom."** `reconciledUniverse = max(estimate, in-TAM)`.
|
|
158
|
+
|
|
159
|
+
Once two-plus readings show movement, `report`/`status` project an **ETA** on the
|
|
160
|
+
**in-TAM** burn rate (off-ICP accounts loaded elsewhere don't count as progress) →
|
|
161
|
+
days remaining → a projected fill date. Fewer than two readings, a flat rate, or no
|
|
162
|
+
elapsed time returns an honest "not enough history yet," never a fabricated date.
|
|
163
|
+
|
|
164
|
+
## Storage
|
|
165
|
+
|
|
166
|
+
Profile-scoped under `<home>/tam/<name>/`:
|
|
167
|
+
|
|
168
|
+
- `model.json` — the TAM model (universe, ACV, cross-checks, baseline). 0600.
|
|
169
|
+
- `coverage.jsonl` — append-only coverage readings (one per `status --save`). 0600.
|
|
170
|
+
|
|
171
|
+
Scope per client with `--profile <name>`; name multiple TAMs with `--name <n>`
|
|
172
|
+
(default `default`).
|
|
173
|
+
|
|
174
|
+
## Invariants
|
|
175
|
+
|
|
176
|
+
- **Estimate is honest about its source.** Every model says whether the account count
|
|
177
|
+
is a `provider:*` probe or an `assumption`; the probe errors rather than silently
|
|
178
|
+
guessing when the provider returns no count.
|
|
179
|
+
- **No fabricated economics.** ACV must be explicit or CRM-derived (closed-won) — there
|
|
180
|
+
are no band defaults, and `estimate` refuses without a real ACV. Buyers/account and
|
|
181
|
+
ACV both carry a `source` label so a thin signal is visible, never hidden.
|
|
182
|
+
- **Population never auto-writes.** `tam populate` schedules plan-only acquire; apply
|
|
183
|
+
is a separate gate, the meter is charged only at apply, and the allowlist requires
|
|
184
|
+
`--save`.
|
|
185
|
+
- **Coverage is deterministic** from the CRM snapshot + the model baseline — no LLM,
|
|
186
|
+
byte-stable, comparable run-over-run.
|
|
187
|
+
- **ETA is refused when unprojectable** rather than faked.
|
|
188
|
+
|
|
189
|
+
## Library
|
|
190
|
+
|
|
191
|
+
`estimateTam`, `computeCoverage`, `coverageCountsFromSnapshot`, `projectEta`,
|
|
192
|
+
`tamReportToMarkdown`, the store (`saveTamModel`/`loadTamModel`/`appendCoverage`/
|
|
193
|
+
`readCoverageTimeline`), the count probe (`probeExploriumBusinessCount` +
|
|
194
|
+
`EXPLORIUM_BUSINESS_COUNT_CAP`), and the ICP→firmographic filter
|
|
195
|
+
(`icpToExploriumBusinessFilters`) are all exported for programmatic use.
|