@anysiteio/agent-skills 2.0.0 → 2.1.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/README.md CHANGED
@@ -41,6 +41,7 @@ Get unlimited requests through Remote MCP Server with no request limits. Ideal f
41
41
  | `anysite-crm-score` | Score CRM companies against your ICP with an explicit rubric and write the score into the mapped field. | [SKILL.md](skills/anysite-crm-score/SKILL.md) |
42
42
  | `anysite-crm-competitor-intel` | Displacement hunting: who uses a competitor (technographics), what their users complain about (review mining), tagged into the CRM. | [SKILL.md](skills/anysite-crm-competitor-intel/SKILL.md) |
43
43
  | `anysite-crm-account-brief` | Pre-meeting one-pager for a CRM account: CRM context + funding, exec changes, news, key people's recent activity. | [SKILL.md](skills/anysite-crm-account-brief/SKILL.md) |
44
+ | `anysite-crm-inbound` | Instant read-only verdict on one inbound lead - identity, company reality check, ICP fit, route and talking points in 2-5 calls. | [SKILL.md](skills/anysite-crm-inbound/SKILL.md) |
44
45
  | `anysite-crm-lookalikes` | Derive your real ICP from closed-won customers and find lookalike companies across 70M+ company records. | [SKILL.md](skills/anysite-crm-lookalikes/SKILL.md) |
45
46
  <!-- END_SKILLS_TABLE -->
46
47
 
package/bin/install.js CHANGED
@@ -30,10 +30,11 @@ const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf
30
30
 
31
31
  const HOME = process.env.HOME || homedir();
32
32
  const CLAUDE_DIR = join(HOME, ".claude");
33
- const CLAUDE_SKILLS_DIR = join(CLAUDE_DIR, "skills");
34
33
  const CODEX_DIR = join(HOME, ".codex");
35
34
  const CODEX_CONFIG = join(CODEX_DIR, "config.toml");
36
- const MANIFEST_PATH = join(CLAUDE_SKILLS_DIR, ".anysite-skills.json");
35
+ // Both agents use the same skills convention: <dir>/skills/<name>/SKILL.md
36
+ const SKILLS_DST = { claude: join(CLAUDE_DIR, "skills"), codex: join(CODEX_DIR, "skills") };
37
+ const manifestPath = (dstRoot) => join(dstRoot, ".anysite-skills.json");
37
38
 
38
39
  const MCP_NAME = "anysite";
39
40
  const MCP_URL = "https://mcp.anysite.io/mcp";
@@ -73,8 +74,8 @@ function skillDescription(name) {
73
74
  } catch { return ""; }
74
75
  }
75
76
 
76
- function readManifest() {
77
- try { return JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); } catch { return null; }
77
+ function readManifest(dstRoot) {
78
+ try { return JSON.parse(readFileSync(manifestPath(dstRoot), "utf8")); } catch { return null; }
78
79
  }
79
80
 
80
81
  function detectTargets() {
@@ -91,21 +92,21 @@ function hasClaudeCli() {
91
92
 
92
93
  // ── skills install / uninstall ──────────────────────────────────────────────
93
94
 
94
- function installSkills(names) {
95
- mkdirSync(CLAUDE_SKILLS_DIR, { recursive: true });
95
+ function installSkills(names, dstRoot) {
96
+ mkdirSync(dstRoot, { recursive: true });
96
97
  const installed = [];
97
98
  const missing = [];
98
99
  for (const name of names) {
99
100
  const src = join(SKILLS_SRC, name);
100
101
  if (!existsSync(join(src, "SKILL.md"))) { missing.push(name); continue; }
101
- const dst = join(CLAUDE_SKILLS_DIR, name);
102
+ const dst = join(dstRoot, name);
102
103
  rmSync(dst, { recursive: true, force: true });
103
104
  cpSync(src, dst, { recursive: true });
104
105
  installed.push(name);
105
106
  }
106
- const prev = readManifest();
107
+ const prev = readManifest(dstRoot);
107
108
  const all = [...new Set([...(prev?.skills ?? []), ...installed])].sort();
108
- writeFileSync(MANIFEST_PATH, JSON.stringify({
109
+ writeFileSync(manifestPath(dstRoot), JSON.stringify({
109
110
  version: PKG_VERSION,
110
111
  bundle: flags.bundle ?? prev?.bundle ?? null,
111
112
  installedAt: new Date().toISOString(),
@@ -114,21 +115,21 @@ function installSkills(names) {
114
115
  return { installed, missing };
115
116
  }
116
117
 
117
- function uninstallSkills() {
118
- const manifest = readManifest();
118
+ function uninstallSkills(dstRoot) {
119
+ const manifest = readManifest(dstRoot);
119
120
  const tracked = new Set(manifest?.skills ?? []);
120
121
  const removed = [];
121
- if (existsSync(CLAUDE_SKILLS_DIR)) {
122
- for (const d of readdirSync(CLAUDE_SKILLS_DIR, { withFileTypes: true })) {
122
+ if (existsSync(dstRoot)) {
123
+ for (const d of readdirSync(dstRoot, { withFileTypes: true })) {
123
124
  if (!d.isDirectory()) continue;
124
125
  // remove tracked skills plus legacy anysite-* installs; never touch anything else
125
126
  if (tracked.has(d.name) || d.name.startsWith("anysite-")) {
126
- rmSync(join(CLAUDE_SKILLS_DIR, d.name), { recursive: true, force: true });
127
+ rmSync(join(dstRoot, d.name), { recursive: true, force: true });
127
128
  removed.push(d.name);
128
129
  }
129
130
  }
130
131
  }
131
- rmSync(MANIFEST_PATH, { force: true });
132
+ rmSync(manifestPath(dstRoot), { force: true });
132
133
  return removed;
133
134
  }
134
135
 
@@ -170,8 +171,7 @@ if (flags.help) {
170
171
  }
171
172
 
172
173
  if (flags.list) {
173
- const manifest = readManifest();
174
- const installed = new Set(manifest?.skills ?? []);
174
+ const installed = new Set(detectTargets().flatMap((t) => readManifest(SKILLS_DST[t])?.skills ?? []));
175
175
  console.log("\nBundles:");
176
176
  for (const [name, b] of Object.entries(BUNDLES)) {
177
177
  console.log(` ${name} (${b.skills.length} skills) — ${b.description}`);
@@ -186,21 +186,27 @@ if (flags.list) {
186
186
  }
187
187
 
188
188
  if (flags.status) {
189
- const manifest = readManifest();
190
- if (!manifest) { console.log("\nNo anysite skills installed (no manifest found).\n"); process.exit(0); }
191
- console.log(`\nInstalled: ${manifest.skills.length} skills (package v${manifest.version}, ${manifest.installedAt})`);
192
- if (manifest.bundle) console.log(`Bundle: ${manifest.bundle}`);
193
- for (const s of manifest.skills) console.log(` ✓ ${s}`);
194
- console.log(`\nUpdate: npx @anysiteio/agent-skills@latest ${manifest.bundle ?? ""}\n`);
189
+ let any = false;
190
+ for (const t of detectTargets()) {
191
+ const manifest = readManifest(SKILLS_DST[t]);
192
+ if (!manifest) { console.log(`\n[${t}] no anysite skills installed.`); continue; }
193
+ any = true;
194
+ console.log(`\n[${t}] ${manifest.skills.length} skills (package v${manifest.version}, ${manifest.installedAt})${manifest.bundle ? `, bundle: ${manifest.bundle}` : ""}`);
195
+ for (const s of manifest.skills) console.log(` ✓ ${s}`);
196
+ if (any) console.log(`\nUpdate: npx @anysiteio/agent-skills@latest ${manifest.bundle ?? ""}`);
197
+ }
198
+ console.log("");
195
199
  process.exit(0);
196
200
  }
197
201
 
198
202
  if (flags.uninstall) {
199
- const removed = uninstallSkills();
200
- console.log(removed.length
201
- ? `\nRemoved ${removed.length} skills from ${CLAUDE_SKILLS_DIR}:\n ${removed.join("\n ")}\n`
202
- : "\nNothing to remove.\n");
203
- console.log(`The MCP entry is kept. To remove it: claude mcp remove ${MCP_NAME}\n`);
203
+ for (const t of detectTargets()) {
204
+ const removed = uninstallSkills(SKILLS_DST[t]);
205
+ console.log(removed.length
206
+ ? `\n[${t}] removed ${removed.length} skills from ${SKILLS_DST[t]}`
207
+ : `\n[${t}] nothing to remove.`);
208
+ }
209
+ console.log(`\nThe MCP entries are kept. To remove: claude mcp remove ${MCP_NAME} / edit ~/.codex/config.toml\n`);
204
210
  process.exit(0);
205
211
  }
206
212
 
@@ -218,9 +224,13 @@ if (!targets.length) {
218
224
  process.exit(1);
219
225
  }
220
226
 
221
- const { installed, missing } = installSkills(names);
222
- console.log(`\nSkills ${CLAUDE_SKILLS_DIR}: ${installed.length} installed${missing.length ? `, unknown: ${missing.join(", ")}` : ""}`);
223
- if (!installed.length) {
227
+ let installedTotal = 0;
228
+ for (const t of targets) {
229
+ const { installed, missing } = installSkills(names, SKILLS_DST[t]);
230
+ installedTotal += installed.length;
231
+ console.log(`\nSkills [${t}] → ${SKILLS_DST[t]}: ${installed.length} installed${missing.length ? `, unknown: ${missing.join(", ")}` : ""}`);
232
+ }
233
+ if (!installedTotal) {
224
234
  console.error("Nothing was installed — check skill names with --list.");
225
235
  process.exit(1);
226
236
  }
package/bundles.json CHANGED
@@ -5,6 +5,7 @@
5
5
  "skills": [
6
6
  "anysite-mcp",
7
7
  "anysite-crm-setup",
8
+ "anysite-crm-inbound",
8
9
  "anysite-crm-enrich",
9
10
  "anysite-crm-signals",
10
11
  "anysite-crm-champions",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anysiteio/agent-skills",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Official anysite agent skills + one-line setup: installs skills into Claude Code / Codex and registers the anysite remote MCP server. Includes the GTM bundle (CRM enrichment, signals, prospecting).",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: anysite-competitor-analyzer
3
- description: Deep competitive intelligence combining web scraping, LinkedIn data, social media monitoring, leadership analysis, GitHub activity, Glassdoor sentiment, and community insights. Analyzes founders/C-level profiles, tracks real-time signals vs quarterly reports, and creates comprehensive competitor profiles. Use when asked to analyze competitors, research leadership teams, investigate market positioning, compare products/pricing, assess strategic threats, or gather intelligence on founders and key executives.
3
+ description: Deep competitive intelligence combining web scraping, LinkedIn data, social media monitoring, leadership analysis, GitHub activity, Glassdoor sentiment, and community insights. Analyzes founders/C-level profiles, tracks real-time signals vs quarterly reports, and creates comprehensive competitor profiles. Use for a DEEP DOSSIER ON ONE named competitor - leadership/founder profiling, product/pricing teardown, strategic threat assessment of a single company. For tracking a landscape of several competitors over time use anysite-competitor-intelligence; for CRM-tied displacement lists use anysite-crm-competitor-intel.
4
4
  ---
5
5
 
6
6
  # Competitor Analyzer
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: anysite-competitor-intelligence
3
- description: Competitive intelligence gathering using anysite MCP server across LinkedIn, social media, Y Combinator, and the web. Track competitor activities, analyze hiring patterns, monitor content strategies, benchmark market positioning, research startup competitors, and gather strategic intelligence. Supports LinkedIn (companies, employees, posts), Instagram, Twitter/X, Reddit, YouTube, Y Combinator, and web scraping. Use when users need to analyze competitors, track competitive movements, research market positioning, monitor hiring velocity, or gather strategic market intelligence.
3
+ description: Competitive intelligence gathering using anysite MCP server across LinkedIn, social media, Y Combinator, and the web. Track competitor activities, analyze hiring patterns, monitor content strategies, benchmark market positioning, research startup competitors, and gather strategic intelligence. Supports LinkedIn (companies, employees, posts), Instagram, Twitter/X, Reddit, YouTube, Y Combinator, and web scraping. Use for tracking a LANDSCAPE of several competitors over time - competitive movements, hiring velocity, content strategies, market positioning across a set of companies. For a deep dossier on one named competitor use anysite-competitor-analyzer; for CRM-tied displacement lists use anysite-crm-competitor-intel.
4
4
  ---
5
5
 
6
6
  # anysite Competitor Intelligence
@@ -21,16 +21,22 @@ an update candidate for `anysite-crm-enrich`/`anysite-crm-champions`.
21
21
 
22
22
  ### 2. Company snapshot
23
23
 
24
- - `execute crunchbase/search {keywords: name}` → alias `crunchbase/company`
25
- funding history, `leadership_hires[]`, `news[]`, `layoffs[]`, employee range, investors.
26
- - `execute linkedin/search/search_sql_companies {website: domain}` description,
24
+ - `execute crunchbase/search {keywords: name}` → alias (verify name+domain, first hit may
25
+ be a namesake) → `crunchbase/company` → funding history, `leadership_hires[]` (often empty
26
+ for smaller companies not a negative), `news[]`, `layoffs[]`, employee range, investors.
27
+ Same response, free extras for the brief: `related.competitors[]` (their competitive set),
28
+ `bombora_surges[]` (what their team is researching — mention only if relevant to the
29
+ meeting), `predictions.funding_score` (likelihood of a next round).
30
+ - `search_sql_companies {website: "<domain>", count: 10}` + exact `website` match check
31
+ (substring search returns look-alike domains — verify before trusting) → description,
27
32
  specialities, locations, employee_count.
28
33
 
29
34
  ### 3. What's happening now
30
35
 
31
- - Hiring: `linkedin/search/search_jobs {company: [{"type": "company", "value": "<id>"}],
32
- sort: "recent", count: 20}` (numeric id from the `fsd_company:<id>` URN) what functions
33
- they're growing (that's their current priorities, use in talking points).
36
+ - Hiring: `search_companies {keywords: "<name>", count: 5}` → pick the right company by
37
+ name/industry/alias (first hit is often a namesake), its `urn` comes back already as the
38
+ `{type, value}` object `search_jobs {company: [<urn object>], sort: "recent", count: 20}`
39
+ — what functions they're growing (that's their current priorities, use in talking points).
34
40
  - News: crunchbase `news[]` first (already fetched); add
35
41
  `techmeme/stories/stories_search {keyword: "<name>", count: 5}` for tech companies.
36
42
  - Employer sentiment (optional, for bigger companies): resolve the employer id first via
@@ -48,9 +54,12 @@ execute linkedin/user/user_posts {urn, count: 10,
48
54
  ```
49
55
  Caveat: `user` called with a URL may omit the `urn` in its response, and `user_posts`
50
56
  accepts ONLY a URN. If the urn is missing, recover it via
51
- `search_users {first_name, last_name, company_keywords, count: 3}` → pick the match →
52
- use its urn. Posts are personalization gold: real interests, stated problems, conference
53
- activity.
57
+ `search_users {first_name, last_name, current_company: [<company urn object>], count: 3}`
58
+ the company-filtered search returns the URN directly (same cascade crm-enrich uses for
59
+ email-only contacts). Posts are personalization gold: real interests, stated problems,
60
+ conference activity. Quiet posters: `user_comments` and `user_reactions` (posts they
61
+ engaged with) reveal what a lurker actually reads — often better meeting fuel than their
62
+ own posts.
54
63
  No posts ≠ no signal — check `user_comments` for lurker activity if it matters.
55
64
 
56
65
  ### 5. The brief
@@ -35,6 +35,15 @@ Audit scope: whole portal if small; else the working list + a stated sample. Say
35
35
  "neither" group is unenrichable and un-dedupable — flag it).
36
36
  - **Duplicate candidates:** same normalized email; same domain with several company records;
37
37
  same person name + company. Candidates only — never auto-merge.
38
+ - **Email ↔ employer mismatch:** contact's email domain vs their linked company's domain.
39
+ A mismatch has THREE causes needing different handling (all seen live): the person changed
40
+ jobs; the COMPANY rebranded/changed domain (old domain usually still receives mail — the
41
+ contact is fine); or the email is genuinely stale. Free discriminator already in the data:
42
+ if crunchbase under the domain-derived alias returns a different company NAME than the
43
+ LinkedIn page of that same domain — that's a rebrand, not a departure. FLAG ONLY, never
44
+ auto-fix in either direction — in two causes out of three there is nothing to "fix", and
45
+ "correcting" the company from the email domain overwrites good data with stale. Job-change
46
+ suspects go to anysite-crm-champions.
38
47
  - **Staleness:** if `anysite_last_enriched_at` (or similar) is mapped — age distribution;
39
48
  contacts with no activity fields; companies with dead domains (spot-check a few via
40
49
  `webparser/parse` only if the user asks).
@@ -35,8 +35,13 @@ Cap a run at ~100 contacts (one profile call each); more → propose batching by
35
35
 
36
36
  Per contact:
37
37
  - `linkedin_url` → `execute linkedin/user/user {user: <url>}` → current experience.
38
- - email only → `execute linkedin/email/email_sql_user {email}` → profile (live `email_user`
39
- for the remainder).
38
+ - email only → try `execute linkedin/email/email_sql_user {email}` (→ live `email_user`),
39
+ but expect misses; the reliable path uses what the CRM already knows: email domain →
40
+ resolve company → `organizational_urn` → `search_users {first_name, last_name,
41
+ current_company: [{"type": "company", "value": "<id>"}]}`. NOTE: for champion tracking
42
+ search by the CRM company tells you where they WERE — a zero-result search there is
43
+ itself a move signal; re-search without the company filter and disambiguate by
44
+ headline/history before concluding.
40
45
 
41
46
  Compare the profile's **current company** against the CRM company. Normalize before
42
47
  comparing (legal suffixes, casing, known rebrands); when unsure, treat as "same" — false
@@ -47,6 +47,14 @@ only). Filter low-rating reviews with `query_cache` (free), then extract with th
47
47
  recurring pains, switching triggers, praised alternatives, verbatim quotes worth reusing.
48
48
  Keep 3–7 pains with quote + source URL each — this is the personalization ammunition.
49
49
 
50
+ Second ammunition source — the competitor's own ads (`ad-transparency` sources, incl.
51
+ LinkedIn Ad Library): their current claims and positioning in their own words. Scope it to
52
+ the 1–3 competitors under analysis (NOT per-account sweeps — per-ad detail is a separate
53
+ call each); use server filters (`impressions_min`, `countries`, `date_option`) and open
54
+ only the cards that matter. ~10–15cr for a whole competitor analysis. Their engagement
55
+ graph (`post_comments`/`post_reactions` on the competitor's page — a seed with a real
56
+ audience) adds who's actively following them.
57
+
50
58
  ### 3. Cross-reference with the CRM
51
59
 
52
60
  ```
@@ -35,9 +35,15 @@ Page through everything in scope. Locally split records into:
35
35
  ### 2. Resolve identities (contacts)
36
36
 
37
37
  - Has `linkedin_url` → `execute linkedin/user/user` (full profile: title, company, location).
38
- - Only email → reverse lookup: `execute linkedin/email/email_sql_user` (cached, cheap) →
39
- remainder via `email_user` (live). Both take ONE email per call — loop, don't batch.
40
- No profile found leave record, report.
38
+ - Only email → try reverse lookup first: `execute linkedin/email/email_sql_user` (cached,
39
+ cheap) → remainder via `email_user` (live). Both take ONE email per call — loop, don't
40
+ batch, and expect misses (verified to return empty even for people who are on LinkedIn).
41
+ Then the cascade that actually works, because the CRM knows the name: email domain →
42
+ resolve company (verified, per anysite-mcp recipe) → `organizational_urn` →
43
+ `search_users {first_name, last_name, current_company: [{"type": "company",
44
+ "value": "<id>"}]}` → usually exactly one match, WITH the profile URN as a bonus.
45
+ Company filter mandatory — bare names return namesakes. Still nothing → leave record,
46
+ report.
41
47
  - Needs email → `user_email` (batch ≤10 profiles, low yield — set expectations honestly).
42
48
  A higher-yield `find_email_by_url` exists in the API but may be disabled in MCP — trust
43
49
  `discover("linkedin", "user")`; if absent, stop at `user_email` and report coverage as-is.
@@ -46,10 +52,42 @@ Re-use cache (`query_cache`) instead of re-fetching anything twice.
46
52
 
47
53
  ### 3. Resolve companies
48
54
 
49
- - By domain: `execute linkedin/search/search_sql_companies {website: "<domain>", count: 1}`
50
- batch by looping domains; gives industry, employee_count, description, locations.
51
- - Deeper firmographics (funding, size range): `crunchbase/search` by name alias →
52
- `crunchbase/company` only when profile maps such fields (cost-aware: 20cr each).
55
+ - By domain with MANDATORY exact verification on every resolve (the `website` search is
56
+ substring match: stripe.com → Soundstripe; stlabs.com five other *labs.com companies).
57
+ Default one domain per call; OR-DSL batching is an optimization with a tax any domain
58
+ that didn't come back exact-matched gets re-queried individually:
59
+ ```
60
+ execute linkedin/search/search_sql_companies {website: "acme.com", count: 5}
61
+ # batched variant: {website: "acme.com|globex.io", count: 10× domains}, then per domain:
62
+ query_cache {conditions: [{"field": "website", "op": "=", "value": "acme.com"}],
63
+ limit: <fetched count>}
64
+ # query_cache filters over the WHOLE cached set; `limit` (default 10) caps only how many
65
+ # rows come BACK. One domain → the default is fine; a multi-domain batch → pass a limit.
66
+ ```
67
+ Only an exact-website match (normalized: lowercase, no protocol/www/path) counts as
68
+ resolved. Gives industry, employee_count, description, locations.
69
+
70
+ **No exact match? Do NOT stop there** — a whole class of domains never appears in its own
71
+ substring results (verified: `{website: "stlabs.com", count: 5}` returns five other
72
+ *labs.com companies and never STLabs, a live company with a LinkedIn page). Standard second
73
+ step, ~1cr:
74
+ ```
75
+ execute webparser/parse {url: "https://acme.com", extract_minimal: true}
76
+ → top-level `title` = who they say they are
77
+ → links[] usually carries their own linkedin.com/company/... URL
78
+ execute linkedin/company {company: "<that URL>"} # exact, no fuzzy matching
79
+ ```
80
+ Only after that fails is the domain genuinely unresolved — report it, never write.
81
+ - Deeper firmographics (funding, size range): take the alias from `crunchbase_link`, which
82
+ the domain-resolve above ALREADY returned — free. Only when that field is empty and the
83
+ company is plausibly venture-backed, fall back to the live `crunchbase/search` by name
84
+ (20cr, fuzzy — verify name+domain before trusting it):
85
+ ```
86
+ # crunchbase_link: "https://www.crunchbase.com/organization/acme" → alias "acme"
87
+ execute crunchbase/company {company: "acme"}
88
+ ```
89
+ Only when the profile maps such fields. Normalize `contacts.email` before use — trailing
90
+ dots observed ("founders@reducto.ai."), and a match key with a trailing dot matches nothing.
53
91
 
54
92
  ### 4. Write back
55
93
 
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: anysite-crm-inbound
3
+ description: Instant read-only verdict on ONE inbound lead - who they really are, whether the company is real and ICP-fit, route suggestion and talking points - in 2-5 anysite calls. Takes an email, a name+company, or a linkedin_url; checks the CRM for prior history but writes nothing. Use when a single new lead just arrived (demo request, reply, form fill, DM) and the user wants a fast qualify/route decision. For batch prospecting use anysite-crm-prospect; for accounts already worked in the CRM use anysite-crm-account-brief.
4
+ ---
5
+
6
+ # CRM Inbound — one lead, one minute
7
+
8
+ Every skill in this pack is batch-shaped except the most frequent moment in GTM life:
9
+ a single lead just landed and someone asks "is this real / are they our ICP / who takes
10
+ the call?". This skill answers that in 2 calls (verdict) to 4–5 calls (full mini-brief).
11
+ Strictly read-only.
12
+
13
+ ## Input
14
+
15
+ Whatever the user has: an email, a name + company, a linkedin_url, or a forwarded
16
+ demo-request text. Extract identifiers yourself; don't interrogate the user.
17
+
18
+ ## Flow (cheap-first, stop as soon as the verdict is clear)
19
+
20
+ ### 1. Identify the person and company
21
+
22
+ - **linkedin_url** → `execute linkedin/user/user {user: <url>}` → done.
23
+ - **email with a work domain** → resolve the company by domain (anysite-mcp recipe: exact
24
+ verify; `webparser/parse` fallback for common-token domains) → `organizational_urn` →
25
+ `search_users {first_name, last_name, current_company: [{"type": "company",
26
+ "value": "<id>"}]}`. Note: `email_sql_user` reverse lookup is a cheap first try but
27
+ verified to miss often — don't stop on its empty result.
28
+ - **email with a personal domain** (gmail etc.) → reverse lookup try, else name+company if
29
+ the form/message carries them. A lead reachable ONLY via personal email = flag it.
30
+ - **name + company** → resolve company → `search_users` with the company filter (bare
31
+ names return namesakes).
32
+
33
+ ### 2. Company reality check (1 call, often already done in step 1)
34
+
35
+ The verified `search_sql_companies` row gives industry, employee_count, locations,
36
+ description, `crunchbase_link`, `organizational_urn`. Funding stage matters →
37
+ `crunchbase/company` via the free alias from `crunchbase_link` (skip for obviously
38
+ non-venture companies).
39
+
40
+ ### 3. CRM history (free, crm_* reads)
41
+
42
+ ```
43
+ crm_query_records(object_type="contacts", emails=[...]) # known already?
44
+ crm_query_records(object_type="companies", search="<domain>") # account history?
45
+ ```
46
+ Existing record with an owner → this is a routing question, not a research question; say
47
+ who owns it. Closed-lost history → the verdict must mention it.
48
+
49
+ ### 4. Verdict
50
+
51
+ One compact block, in this order:
52
+ 1. **Real?** — person verified (profile ↔ claimed company match), company verified.
53
+ 2. **ICP fit** — against the profile's known criteria (or the user's stated ICP); one line
54
+ of evidence per criterion, "unknown" where no data.
55
+ 3. **Route** — new/known, suggested owner if CRM history names one, urgency (fresh funding
56
+ or hiring in the buyer function raises it).
57
+ 4. **3 talking points** with dates and links (recent posts, funding, launches, hiring).
58
+ 5. **Red flags** — personal-email-only, unresolvable company, competitor employee,
59
+ student/job-seeker pattern.
60
+
61
+ ## Rules
62
+
63
+ - **Read-only.** No upserts, no dry-runs. If the user wants the lead in the CRM afterwards,
64
+ hand off to `anysite-crm-prospect` (its dedup and create rules apply).
65
+ - Never claim "verified" on an unverified identity — the namesake trap applies to inbound
66
+ more than anywhere (people misspell their own company in forms).
67
+ - Cost: ~2 credits for a verdict, 4–5 calls for the full brief. Cheap enough to run on
68
+ every inbound; say so if the user hesitates.
@@ -21,10 +21,20 @@ explicit pick of 10–30 names. Fewer than ~8 seeds → warn that the pattern wi
21
21
 
22
22
  ### 2. Profile the seeds
23
23
 
24
- Resolve each seed to structured firmographics:
24
+ Resolve each seed to structured firmographics — exact verification is mandatory on every
25
+ resolve (the `website` search is substring match and can return only look-alike domains;
26
+ a wrong seed poisons the whole ICP pattern downstream):
25
27
  ```
26
- execute linkedin/search/search_sql_companies {website: "<domain>", count: 1} # per seed
28
+ execute linkedin/search/search_sql_companies {website: "seed1.com", count: 5} # per seed
29
+ # batched variant allowed, but: any seed without an exact match must be re-queried
30
+ # individually. query_cache filters the WHOLE cached set; `limit` (default 10) caps only
31
+ # how many rows come back — pass one when a batch should return more than 10 matches.
32
+ query_cache {conditions: [{"field": "website", "op": "=", "value": "seed1.com"}], limit: 50}
27
33
  ```
34
+ A seed with no exact website match is NOT dropped yet — resolve it via the site itself
35
+ (`webparser/parse {url, extract_minimal: true}` → top-level `title` + own linkedin.com/company
36
+ URL in `links[]` → `linkedin/company`), or via crunchbase → `contacts.linkedin_url`. Only a
37
+ seed that survives neither is excluded from profiling, and say which ones.
28
38
  Plus `crunchbase/company` for stage/funding on a subset (venture-relevant seeds only).
29
39
  Derive the pattern in-session and SHOW it:
30
40
 
@@ -33,6 +43,11 @@ Industries: X (60%), Y (25%) · Size: 11-200 dominant · Geo: US+UK 80%
33
43
  Stage: seed-B · Common traits: has API docs page, hiring in data roles, ...
34
44
  ```
35
45
 
46
+ Build the size band from `employee_count`, never from `employee_count_range` — the two can
47
+ contradict each other in one record (verified: `employee_count: 1465` with
48
+ `employee_count_range: "201-500"`), and a wrong band here propagates into every search
49
+ below. Bucket the exact counts yourself.
50
+
36
51
  The user confirms/edits the pattern — it's their ICP, the data only proposes it.
37
52
 
38
53
  ### 3. Search for lookalikes
@@ -65,9 +65,12 @@ crm_upsert_contacts(records=[{email | linkedin_url,
65
65
  associate_company_domain: <domain>}],
66
66
  allow_create=true, dry_run=true) → confirm → write
67
67
  ```
68
- Server requires email to create a contact; contacts without email that don't match an
69
- existing record will be skipped with a warningreport them as "found, pending email",
70
- don't retry blindly. When associating to companies created in the same run, prefer
68
+ Before pushing, split found emails by domain: personal addresses (gmail/yahoo/outlook and
69
+ similar `user_email` returns mostly these) are NOT work emails never feed them into a
70
+ work-email sequence; keep those leads in a "personal email only" bucket alongside
71
+ "pending email", and say so in the report. Server requires email to create a contact;
72
+ contacts without email that don't match an existing record will be skipped with a
73
+ warning — report them as "found, pending email", don't retry blindly. When associating to companies created in the same run, prefer
71
74
  `associate_company_id` from the company upsert result. Save `run_id`s.
72
75
 
73
76
  Note: crm_* tools cannot add records to CRM lists (list_id is read-only in queries). If
@@ -36,12 +36,33 @@ explainable and reproducible.
36
36
  ```
37
37
  crm_query_records(object_type="companies", ...) → record_id, name, domain, existing fields
38
38
  ```
39
- - Base firmographics: `execute linkedin/search/search_sql_companies {website: <domain>}`
40
- batch resolve; industry, employee_count, locations, description.
41
- - Stage/funding (only if the rubric needs it): `crunchbase/search` alias
42
- `crunchbase/company` (cache aliases; skip for obviously non-venture companies).
43
- - Hiring probe (only if in rubric): `linkedin/search/search_jobs` with
44
- `company: [{"type": "company", "value": "<numeric id from fsd_company urn>"}]`.
39
+ - Base firmographics: `search_sql_companies` by `website` — default one domain per call;
40
+ OR-DSL batching (`{website: "a.com|b.com|...", count: 10× domains}`) is an optimization
41
+ with a verification tax (see the anysite-mcp resolve recipe). Never `count: 1` the search
42
+ is substring match, and a common-token domain comes back with only look-alikes even in a
43
+ single-domain call. Verify the exact `website` match per domain via `query_cache` with an
44
+ explicit `limit` (default is 10 a 20-domain batch needs more). Unverified match = no
45
+ evidence, score that criterion "unknown"; a domain that never comes back exact-matched is
46
+ resolved via `webparser/parse` on the site itself, per the same recipe.
47
+ - Stage/funding (only if the rubric needs it): take the alias from `crunchbase_link`, which
48
+ the domain-resolve above ALREADY returned — free, no lookup. Only when it is empty and the
49
+ company is plausibly venture-backed, fall back to the live `crunchbase/search` (20cr, fuzzy
50
+ — verify name+domain) → `crunchbase/company`. Skip entirely for obviously non-venture
51
+ companies. Note `leadership_hires[]` is unusable as an ICP criterion for SMB/startup targets
52
+ — measured empty on 6 of 6 live accounts, including a 281-person one.
53
+ - Hiring probe (only if in rubric): prefer the numeric id from `organizational_urn` of the
54
+ domain-resolve you already did → `search_jobs {company: [{"type": "company", "value":
55
+ "<id>"}], count: 20}`. No resolve → `search_companies {keywords: name, count: 5}` +
56
+ verify by name/industry (its `urn` is already the `{type, value}` object).
57
+ - Team-shape evidence (great for "engineering-led vs sales-led" criteria):
58
+ `linkedin/company/company_employee_stats` (1cr, needs company URN) — absolute headcounts
59
+ by function (verified: Engineering 26 / Sales 14 on a 79-person company). Don't sum its
60
+ `locations` array (nested buckets: US ⊃ state ⊃ metro); cross-check totals against
61
+ `employee_count`.
62
+
63
+ Company size in the rubric: use `employee_count`, never `employee_count_range` — the two
64
+ can contradict each other in one record (verified: 1465 vs "201-500"), and the range would
65
+ misfile the size band silently. Range only as fallback when the count is empty, noted.
45
66
 
46
67
  Skip any evidence source whose rubric weight is zero. State per-company data gaps —
47
68
  a company with missing data gets a confidence note, not a silently low score.
@@ -61,6 +61,8 @@ Ask ONLY about ambiguous or high-stakes decisions, one compact block, not an int
61
61
  - Overwrite policy for volatile fields: "Job titles: overwrite on change or fill blanks only?"
62
62
  - Working list: "Which list do you enrich most — 'Inbound' or 'Outbound Q3'?" (only if lists exist)
63
63
  - Creation policy: "May agents create new contacts, or update existing only?"
64
+ - Plan: "Are you on MCP Unlimited or a credit-based plan?" — future sessions use this to
65
+ decide between credit estimates (credit plans) and time estimates (Unlimited) before bulk runs
64
66
 
65
67
  Confirm the full mapping as ONE list for approval, then save.
66
68
 
@@ -97,6 +99,8 @@ Generated <date> by anysite-crm-setup from live schema. Do not edit manually.
97
99
  - allow_create: <true/false, as agreed>
98
100
  - Never write (user protected, on top of server-enforced): <list or "-">
99
101
  - Enum properties and their valid values: <property>: <values>
102
+ - MCP plan: <unlimited | credits>; bulk-run policy: <credit estimates | time estimates>
103
+ - crunchbase aliases cache: <name → alias, append as resolved — saves ~20cr/account/sweep>
100
104
  ```
101
105
 
102
106
  Only include rows that were actually agreed or auto-mapped. Never invent mappings for data types
@@ -32,26 +32,44 @@ propose splitting or narrowing.
32
32
 
33
33
  Run the cheap universal chain for every account; add optional probes when relevant.
34
34
 
35
- **Funding / exec hires / news / layoffs (one lookup covers four signals):**
35
+ **Funding / exec hires / news / layoffs / intent (one lookup covers five signals):**
36
36
  ```
37
- execute crunchbase/search {keywords: "<company name>", count: 3} # resolve alias, once
37
+ # The domain-resolve you already did (search_sql_companies) returns `crunchbase_link`
38
+ # extract the alias from it for FREE. Only when crunchbase_link is empty AND the company
39
+ # is plausibly venture-backed, fall back to the expensive live search:
40
+ execute crunchbase/search {keywords: "<company name>", count: 3} # 20cr/50 — last resort
38
41
  execute crunchbase/company {company: "<alias>"}
39
42
  → funding_rounds[] (date, type, amount, lead investors)
40
- → leadership_hires[] (date, role, description)
43
+ → leadership_hires[] (date, role, description) — measured live: EMPTY on 6 of 6
44
+ SMB/startup accounts (incl. a 281-person, 10-year-old company; sample: US tech/AI).
45
+ Never build the Act-now tier on its presence for that ICP; empty ≠ "no hires"
41
46
  → news[] (title, date, publisher)
42
47
  → layoffs[]
48
+ → bombora_surges[] (free intent bonus: topics the account's staff is researching.
49
+ Count it ONLY if a topic matches the user's product category — it reflects what
50
+ they buy, not that they need you; weak-moderate on its own, good as a stack booster)
43
51
  ```
44
- Alias resolution is case-sensitive and costs credits if the profile maps a
45
- `crunchbase_alias` field, read/write it so each account is resolved once, ever.
52
+ Alias hygiene: aliases are case-sensitive (412 on miss re-resolve once, likely rebrand).
53
+ Keep a name alias table in the local profile file so future sweeps skip resolution
54
+ entirely — the profile is a local file, it works even when CRM custom fields can't be
55
+ created. Coverage honesty: `crunchbase_link` is filled mostly for venture-backed companies
56
+ (≈3/10 in a live batch); bootstrapped/service companies often have NO Crunchbase record —
57
+ for them skip the crunchbase probe entirely instead of fuzzy-searching a wrong match.
46
58
 
47
59
  **Hiring (what they're building):**
48
60
  ```
49
- execute linkedin/search/search_companies {keywords: "<name>", count: 1}
50
- urn like "fsd_company:1441" numeric id "1441"
61
+ # Preferred: the domain-resolve response already carries `organizational_urn`
62
+ # ("company:1441") take the numeric id from it, no extra search needed:
51
63
  execute linkedin/search/search_jobs {company: [{"type": "company", "value": "1441"}],
52
64
  count: 20, sort: "recent"}
65
+ # Only for accounts that were never domain-resolved:
66
+ execute linkedin/search/search_companies {keywords: "<name>", count: 5}
67
+ → pick the RIGHT company by name + industry + alias (first hit is often a namesake:
68
+ "Notion" returns a Media Production company first, notionhq second)
69
+ → its urn is already {"type": "company", "value": "<id>"} — pass through as-is
53
70
  ```
54
- The `company` filter takes typed objects with the numeric id, not raw URN strings.
71
+ A wrong-company URN turns someone else's vacancies into a fake hiring signal worse than
72
+ no signal. Unsure which company is right → skip the hiring probe for that account, say so.
55
73
  Look for roles in the buyer function (e.g. RevOps/Growth/Data roles for a data product).
56
74
 
57
75
  **Mentions / social activity (optional):**
@@ -61,13 +79,28 @@ execute linkedin/search/search_posts {keywords: "\"<company name>\"",
61
79
  execute techmeme/stories/stories_search {keyword: "<company name>", count: 5}
62
80
  ```
63
81
  Do NOT use gdelt (times out). Filter false positives for generic company names by checking
64
- the author/context before counting a mention as a signal.
65
-
66
- ### 3. Score and stack
67
-
68
- Per account, count signals in the last 30/90 days, weighted by conversion value:
69
- exec hire in buyer function > funding round > hiring surge in relevant roles > news >
70
- mentions. Layoffs = negative budget signal for expansion, positive for cost-saving pitches —
82
+ the author/context before counting a mention as a signal. For SMALL accounts, where keyword
83
+ search finds nothing, the better probe is the company's own feed:
84
+ `linkedin/company/company_posts` (~1cr/10) hiring announcements there name new people in
85
+ `mentioned[]` with vanity aliases (new-hire signal + a warm contact in one call).
86
+
87
+ ### 3. Score and stack and filter out what was already reported
88
+
89
+ **Novelty check first:** if the profile maps signal fields, you pulled `last_signal_date` /
90
+ `last_signal_type` in step 1 — a "signal" older than or equal to what the CRM already
91
+ records is NOT news. Without it, a funding round from three months ago gets re-announced as
92
+ fresh on every sweep and the user stops trusting the report. Previously-known signals go
93
+ into a collapsed "already reported" section, never into Act now.
94
+
95
+ Then, per account, count NEW signals in the last 30/90 days, weighted by conversion value —
96
+ and the weights are ICP-dependent, because signal AVAILABILITY is:
97
+ - **SMB/startup ICP:** lead with funding rounds and job postings in the buyer function —
98
+ both filled on every account measured; treat `leadership_hires[]` as a bonus when present
99
+ (it was empty on the whole live sample), catching exec changes via job postings and
100
+ company_posts instead.
101
+ - **Enterprise/press-covered ICP:** exec hire in buyer function > funding round > hiring
102
+ surge in relevant roles > news > mentions (appointments there do reach the press feeds
103
+ that fill leadership_hires). Layoffs = negative budget signal for expansion, positive for cost-saving pitches —
71
104
  interpret against the user's product.
72
105
 
73
106
  Output tiers: **Act now** (2+ fresh signals), **Watch** (1 signal), **Quiet**.
@@ -30,8 +30,13 @@ is the map: how to call them, which sources cover which GTM need, and how to not
30
30
  and paging of that result is free. Never re-run `execute` to look at the same data twice.
31
31
  4. **Cheap-first cascade.** When several endpoints can answer, call the cached/DB one first
32
32
  (`*/db/*`, `*sql*` endpoints, ~1 credit) and the live one only for the remainder.
33
- 5. **Estimate volume before bulk runs.** `N targets × credits-per-call`. Say the number to the
34
- user before launching anything above ~100 calls.
33
+ 5. **Estimate volume before bulk runs plan-aware.** First know the user's plan (the CRM
34
+ profile stores it after setup; if unknown, ask once: MCP Unlimited or credit-based?).
35
+ - **Credit-based plan:** before anything above ~100 calls, state the estimate
36
+ (`N targets × credits-per-call`) and get a nod. Prefer cheap DB endpoints, batch hard.
37
+ - **MCP Unlimited:** credit warnings off, but keep batch sizes sane anyway — the real
38
+ limits are latency and upstream rate limits, so cap sweeps the same way and say
39
+ "this will take ~N minutes" instead of a price.
35
40
  6. **Avoid `gdelt`** — it has repeatedly timed out in practice. Use techmeme or google news
36
41
  instead.
37
42
 
@@ -40,8 +45,42 @@ is the map: how to call them, which sources cover which GTM need, and how to not
40
45
  **Company discovery (bulk):**
41
46
  - `linkedin/search/search_sql_companies` — the workhorse. Up to 1000 companies per call with
42
47
  DSL filters (keywords, industry_name, employee_count_min/max, country_hq, founded_on_min/max,
43
- has_website). Also does batch lookup by `urn` list and search by `website` — use it to
44
- resolve domains from a CRM into LinkedIn company records.
48
+ has_website). Also does batch lookup by `urn` list and search by `website`.
49
+ ⚠️ **`website` search is SUBSTRING match, ordered by last_modified. Verification is
50
+ MANDATORY on every resolve — position in the results means nothing.** Verified live:
51
+ `{website: "stripe.com", count: 1}` → Soundstripe; `{website: "stlabs.com", count: 5}` →
52
+ five *labs.com companies, none of them stlabs.com (common tokens flood the result even in
53
+ a single-domain call). The domain-resolve rules:
54
+ 1) **Verify exact `website` match** (normalize both sides: lowercase, strip
55
+ protocol/`www.`/path) on EVERY resolve, single or batched. No exact match =
56
+ **unresolved** — never write anything to the CRM for it; wrong-company data lands in
57
+ blank fields where nobody will catch it.
58
+ 2) Default: one domain per call, small count. OR-DSL batching
59
+ (`{website: "a.com|b.io", count: 10× domains}`) is an optimization with a verification
60
+ tax: a domain with a common token can be flooded out of the batch entirely — every
61
+ domain that didn't come back exact-matched must be re-queried individually.
62
+ 3) `query_cache` filters over the WHOLE cached set (verified) but returns at most `limit`
63
+ rows (default 10) — pass an explicit `limit` when you expect more matches back. Sanity
64
+ rule: `aggregate {op: "count"}` should equal the `total` from execute; if not, page
65
+ with `get_page` before concluding anything.
66
+ 4) A whole class of domains never appears in its own substring results (common-token
67
+ domains like stlabs.com) — so the website's own page is the STANDARD second step, not
68
+ an emergency: `webparser/parse {url: "https://<domain>", extract_minimal: true}` →
69
+ top-level `title` says who they are, `links[]` usually carries their own
70
+ linkedin.com/company/... URL → `linkedin/company` for the exact URN (verified, ~1cr).
71
+ Live shape on stlabs.com: `title: "STLabs — Intelligent Service Management"` at the TOP
72
+ level, while `metadata` came back `{}` and `cleaned_html` empty — read `title`, and treat
73
+ `metadata` as a fallback only, not the primary location.
74
+ Secondary fallback: `crunchbase/search` by name → `contacts.linkedin_url`. Name search
75
+ alone is never a source of truth.
76
+ Bonus from a successful resolve: the `search_sql_companies` row already carries
77
+ `crunchbase_link` (free crunchbase alias — skip the live 20cr search) and
78
+ `organizational_urn` (`company:<id>` — the numeric id goes straight into `search_jobs`).
79
+ ⚠️ For company SIZE use `employee_count`, never `employee_count_range` — the two fields
80
+ can contradict each other in the same record (verified: Clay returns `employee_count:
81
+ 1465` alongside `employee_count_range: "201-500"`). The range field looks like the natural
82
+ key for size segmentation and would misfile that company by ~3x, silently. Fall back to
83
+ the range only when the exact count is empty, and say that you did.
45
84
  - `crunchbase/db/db_search` — filters by funding stage, last funding date, investors,
46
85
  employee range; count ≤100, dates as Unix timestamps. 1 credit/result. Response includes
47
86
  `funding_rounds[]`, `leadership_hires[]`, `layoffs[]`, `news[]`, `technologies[]`,
@@ -53,8 +92,37 @@ is the map: how to call them, which sources cover which GTM need, and how to not
53
92
  early-stage supplements (check discover for exact endpoint names before calling).
54
93
 
55
94
  **Company detail:** `crunchbase/company` (by alias — resolve via `crunchbase/search` first),
56
- `linkedin/company`. Note: `owler` endpoints need an owler alias and its search has no
57
- name/keyword parameternot usable for looking up a named account.
95
+ `linkedin/company`. One `crunchbase/company` call also carries free extras worth reading:
96
+ `bombora_surges[]` (B2B intent topics but they show what THAT company's staff researches,
97
+ i.e. what they BUY; treat as a signal only when a topic matches what the user sells),
98
+ `related.competitors[]`, `predictions.funding_score`, `awards[]`. Coverage caveat:
99
+ `leadership_hires[]` is often EMPTY for smaller companies — absence of the field is not
100
+ absence of hires. Normalize `contacts.email` (trailing dots observed: "x@y.ai.").
101
+ A third resolve path when crunchbase is already fetched: `contacts.linkedin_url` →
102
+ `linkedin/company` → exact URN (verified; bypasses both fuzzy searches).
103
+ Note: `owler` endpoints need an owler alias and its search has no name/keyword parameter —
104
+ not usable for looking up a named account.
105
+
106
+ **Engagement graph (who interacted with content):** `linkedin/post/post_comments`,
107
+ `post_reactions`, `post_reposts` and `linkedin/company/company_posts` (~1cr/10) — answers
108
+ "who paid attention to this content", incl. people outside your title filters. Identifiers:
109
+ comments/reposts carry a vanity alias; reactions give only an obfuscated `/in/ACoAA...` URL
110
+ plus `internal_id` — `user_email` accepts the `internal_id`, never the obfuscated URL.
111
+ Honest scaling: volume follows the SEED's audience, not the target's importance (large brand
112
+ post → dozens of engagers; 80-person company → 0–2 per post), and on a small account those
113
+ few are mostly the company's OWN staff plus engagement farmers (verified: 3 of 4 commenters
114
+ were employees) — filter by the author's company first and expect nothing left. Use this on
115
+ seeds with a real audience (a competitor's page), not on SMB target lists. For small accounts
116
+ the reliable nugget is `company_posts` → `mentioned[]`: hiring announcements name new people
117
+ with their vanity aliases. `linkedin/company/company_employee_stats` (1cr) gives
118
+ function/skill/location breakdown — cross-check totals against `employee_count` from
119
+ `linkedin/company` before trusting absolutes, and never sum the `locations` array: its
120
+ buckets are nested (US ⊃ California ⊃ SF Bay Area), so summing double-counts badly. Its
121
+ `llm_hint` promises seniority and growth trends that the response does not contain.
122
+
123
+ **Ads as a budget signal:** the `ad-transparency` sources (incl. LinkedIn Ad Library) are
124
+ untapped — a company running B2B ads is telling you it has budget and who its ICP is.
125
+ Listing is cheap; per-ad detail is a separate call each (N+1) — budget accordingly.
58
126
 
59
127
  **People:** `linkedin/search/search_users` (use `job_title` + `current_company` or
60
128
  `company_keywords`; never bare `keywords` alone — returns empty), `linkedin/user` (full
@@ -62,20 +130,34 @@ profile, needs alias/URL/URN — never guess the alias), `linkedin/user/user_pos
62
130
  `user_experience`, `user_comments`.
63
131
 
64
132
  **Email finding (cascade, cheap → expensive):**
65
- 1. `linkedin/user/user_email` — batch up to 10 profiles, cheap, low yield.
133
+ 1. `linkedin/user/user_email` — batch up to 10 profiles, cheap, low yield. Truths from live
134
+ testing: it returns mostly PERSONAL addresses (gmail/yahoo), one row per EMAIL — not per
135
+ profile (group by `alias`/`internal_id` or you duplicate contacts), and its `found` field
136
+ is always true (useless as a check). Personal addresses are not work emails — never
137
+ present them as outreach-ready.
66
138
  2. `linkedin/user/find_email_by_url` — by vanity URL, high yield but expensive (50cr).
67
139
  **May be disabled on the server** — trust `discover("linkedin", "user")`: if it is not
68
140
  listed there, it does not exist; stop at step 1 and say so honestly.
69
- 3. No email found → keep the lead anyway; CRM contact upserts match by `linkedin_url` too
70
- (but note: creating a NEW contact requires an email — no email means update-only).
71
-
72
- **Reverse lookup (email → person):** `linkedin/email/email_sql_user` (cached DB) →
73
- `linkedin/email/email_user` (live) for the remainder.
141
+ 3. No work email found → keep the lead anyway; CRM contact upserts match by `linkedin_url`
142
+ too (but note: creating a NEW contact requires an email — no email means update-only).
143
+
144
+ **Reverse lookup (email → person), reliability order:**
145
+ 1. `linkedin/email/email_sql_user` (cached DB) → `email_user` (live) cheap, but verified
146
+ to return empty even for people who are definitely on LinkedIn. Try, don't rely.
147
+ 2. The cascade that works when you know the name (a CRM does): email domain → resolve the
148
+ company (verified, see above) → `organizational_urn` → `search_users {first_name,
149
+ last_name, current_company: [{"type": "company", "value": "<id>"}]}` → usually exactly
150
+ one match, delivered WITH the `fsd_profile` URN needed for `user_posts`. The company
151
+ filter is mandatory — a bare name returns namesakes.
74
152
 
75
153
  **Hiring signals:**
76
154
  - `linkedin/search/search_jobs` — by company; works for any company. The `company` param
77
- takes `[{"type": "company", "value": "<numeric id>"}]` — extract the id from the
78
- `fsd_company:<id>` URN returned by `search_companies` (raw URN strings are not accepted).
155
+ takes `[{"type": "company", "value": "<numeric id>"}]`. `linkedin/search/search_companies`
156
+ returns `urn` ALREADY in that object form pass it through as-is. Only `search_sql_companies`
157
+ returns string URNs (`fsd_company:<id>`) — there, extract the numeric id yourself. And
158
+ verify the company before using its URN: the first search hit is often a namesake
159
+ (verified: "Notion" → NOTION Media Production first, the real notionhq second) — check
160
+ name + industry + alias.
79
161
  - `greenhouse/jobs/jobs_search {board_token, count}` — full descriptions via `content=true`;
80
162
  `ashby/jobs/jobs_search {board_name, count}` — descriptions always included. Both need the
81
163
  company slug; 412 = wrong token, fall back to linkedin jobs.
@@ -111,12 +193,14 @@ Covers any URL when no named source fits. Web search: `duckduckgo/search`, `brav
111
193
  The standard pattern for account signals (used by the crm-signals skill):
112
194
 
113
195
  ```
114
- company name/domain
115
- crunchbase/search (resolve alias, once; cache it)
116
- crunchbase/companyfunding_rounds, leadership_hires, news, layoffs
117
- linkedin search_jobs(company urn)what they hire for
118
- linkedin search_posts(company name, past-month)mentions
196
+ company domain
197
+ search_sql_companies {website} + exact verify (firmographics
198
+ crunchbase_linkalias FREE ↳ organizational_urn)
199
+ crunchbase/company {alias}funding_rounds, leadership_hires, news, layoffs, bombora
200
+ search_jobs {company: [{type, value from organizational_urn}]} what they hire for
201
+ → search_posts (company name, past-month) → mentions
119
202
  ```
203
+ Three paid calls per account instead of four — the live crunchbase/search drops out.
120
204
 
121
205
  Stack signals: one signal is a guess, 2–3 signals within ~30 days is a pattern worth acting on.
122
206