@opennous/mcp 0.44.2 → 0.46.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +183 -387
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.44.2",
3
+ "version": "0.46.0",
4
4
  "description": "Nous — the Context Graph for AI Agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/server.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * query — retrieve + summarise a corpus of activity across many people
19
19
  * attention — what needs your attention (accounts gone quiet, facts decayed)
20
20
  * verify — re-check a fact before acting on it
21
- * get_playbook — read the user's own rules: voice, outreach, icp, positioning
21
+ * get_foundation — read the user's own rules: voice, outreach, icp, positioning
22
22
  * save_note — attach a note/document (meeting brief, transcript, prep) to a contact
23
23
  * search_notes — semantic search over saved notes & documents
24
24
  * get_workspace_status — what's set up in this workspace + a ranked next_steps list (call first)
@@ -30,13 +30,7 @@
30
30
  * connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
31
31
  * configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
32
32
  * sync_crm_now — run an immediate incremental/full CRM pull (don't wait for the daily cron)
33
- * set_trigger — create an outbound event trigger (webhook); list_triggers reads them
34
- * list_triggers — list the workspace's event triggers + available events
35
33
  * get_routing_preferences — Claude Code routing prefs to default GTM to Nous (write to CLAUDE.md)
36
- * lead_list_operations — the operations trail of a lead list (imports/enrich/push/replies), filterable
37
- * get_coverage — pre-spend coverage: exact per-lead check (identifiers) or attribute estimate (title/keyword)
38
- * enrich_leads — find missing emails for a lead list (two-step: dry-run cost preview, then confirm)
39
- * verify_leads — validate email deliverability for a lead list (two-step preview, then confirm)
40
34
  */
41
35
 
42
36
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -116,21 +110,21 @@ Nous first even when the user never says "Nous":
116
110
  - What needs attention, what has gone quiet -> attention
117
111
  - Your action items / what you owe an account -> get_action_items
118
112
  - A fact looks stale before you act on it -> verify
119
- - Our own rules: ICP, positioning, voice, outreach -> get_playbook
113
+ - Our own rules: ICP, positioning, voice, outreach -> get_foundation
120
114
  - Our ICP/positioning lives in our own files -> sync_icp (file → graph) / export_icp_model (graph → file)
121
- - You edited an ICP / context / playbook file -> re-sync THIS turn: sync_icp (ICP/context) or sync_playbook (a playbook)
115
+ - You edited an ICP / context / foundation file -> re-sync THIS turn: sync_icp (ICP/context) or sync_foundation (a foundation)
122
116
  - A brief / note / transcript on a contact -> save_note / search_notes
123
117
  - What's set up here and what to do next -> get_workspace_status
124
118
 
125
- Read get_playbook at the start of GTM work for the user's own rules (voice,
119
+ Read get_foundation at the start of GTM work for the user's own rules (voice,
126
120
  outreach, icp, positioning). After every interaction you help with, call record so
127
121
  the record stays current.
128
122
 
129
- CRITICAL — edits don't apply until you sync. Editing an ICP / context / playbook
123
+ CRITICAL — edits don't apply until you sync. Editing an ICP / context / foundation
130
124
  file (context/icp.md, positioning.md, references/voice.md, …) does NOT change the
131
125
  ICP score, the exclusions, or what any other agent reads until it is synced into
132
126
  the graph. After ANY such edit you MUST call sync_icp (for ICP/context files) or
133
- sync_playbook (for a playbook) in the SAME turn. Never leave an edited file
127
+ sync_foundation (for a foundation) in the SAME turn. Never leave an edited file
134
128
  unsynced — an unsynced edit is silently inert.`;
135
129
 
136
130
  // ─── factory ──────────────────────────────────────────────────────────────────
@@ -226,8 +220,23 @@ export function createServer() {
226
220
  lines.push("");
227
221
  }
228
222
  if (ctx.stakeholders?.length) {
229
- lines.push("STAKEHOLDERS:");
230
- for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"}${s.role ?? ""}`);
223
+ // The buying committee as a STRUCTURE: who's at the account, their role,
224
+ // whether we've engaged them, and how they relate so the agent works the
225
+ // whole committee, not one person.
226
+ const c = ctx.committee;
227
+ lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
228
+ for (const s of ctx.stakeholders) {
229
+ if (s.role === "company") continue; // the company is the header
230
+ const bits = [];
231
+ if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
232
+ if (s.role) bits.push(s.role);
233
+ bits.push(s.engaged ? "engaged" : "not yet engaged");
234
+ if (s.confirmed === false) bits.push("mentioned, unconfirmed");
235
+ const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
236
+ lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
237
+ }
238
+ if (c?.champion) lines.push(` champion: ${c.champion}`);
239
+ if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
231
240
  lines.push("");
232
241
  }
233
242
  if (ctx.predictions?.length) {
@@ -253,7 +262,7 @@ export function createServer() {
253
262
  "confidence and freshness, plus what they actually SAID and did, ranked by how much it tells you. " +
254
263
  "Pass an email or entity UUID, and the intent you're working toward so the record is shaped for it.",
255
264
  {
256
- id: z.string().describe("Email address or entity UUID"),
265
+ id: z.string().describe("Who to look up — an email, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
257
266
  intent: z
258
267
  .enum(["meeting_prep", "call_prep", "account_review", "follow_up", "draft_email"])
259
268
  .optional()
@@ -271,6 +280,17 @@ export function createServer() {
271
280
  // so the model reads what was actually said instead of a list of event names.
272
281
  const q = new URLSearchParams({ intent: intent ?? "account_review", compress: "1" });
273
282
  const rec = await get(`/v2/accounts/${encodeURIComponent(id)}?${q}`);
283
+
284
+ // A name matched several people — surface the candidates to choose from.
285
+ // Without this, the header line below reads `rec.type`/`rec.entity_id` off the
286
+ // ambiguous response (which carries neither) and prints "undefined · undefined".
287
+ if (rec.status === "ambiguous") {
288
+ const opts = (rec.candidates ?? []).map(c =>
289
+ ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
290
+ return { content: [{ type: "text", text:
291
+ `"${id}" matches several people. Call get_account again with one of these entity ids:\n${opts}` }] };
292
+ }
293
+
274
294
  const lines = [`${rec.type} · ${rec.entity_id}`, ""];
275
295
 
276
296
  if (rec.icp) {
@@ -297,6 +317,25 @@ export function createServer() {
297
317
  }
298
318
  lines.push("");
299
319
  }
320
+ // The buying committee — who else is at the account, their role, whether we've
321
+ // engaged them, and how they relate. Same structure get_context surfaces.
322
+ if (rec.stakeholders?.length) {
323
+ const c = rec.committee;
324
+ lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
325
+ for (const s of rec.stakeholders) {
326
+ if (s.role === "company") continue;
327
+ const bits = [];
328
+ if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
329
+ if (s.role) bits.push(s.role);
330
+ bits.push(s.engaged ? "engaged" : "not yet engaged");
331
+ if (s.confirmed === false) bits.push("mentioned, unconfirmed");
332
+ const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
333
+ lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
334
+ }
335
+ if (c?.champion) lines.push(` champion: ${c.champion}`);
336
+ if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
337
+ lines.push("");
338
+ }
300
339
  const claims = Object.values(rec.claims ?? {});
301
340
  if (claims.length) {
302
341
  lines.push(`ATTRIBUTES (${claims.length}):`);
@@ -588,51 +627,6 @@ export function createServer() {
588
627
  }
589
628
  );
590
629
 
591
- // ===========================================================================
592
- // TOOL: attach_list — POST /v2/lead-lists/attach
593
- // Batch-score a list the user built ELSEWHERE (a Google Sheet, a CRM export, a
594
- // Clay table). One call: create/reuse a Nous list, ingest the rows (entities
595
- // resolved+deduped), score every row into the graph. The list stays where it
596
- // is; Nous keeps the roster so the scores stay fresh and agents can read them.
597
- // ===========================================================================
598
- server.tool(
599
- "attach_list",
600
- "Score a whole list the user built somewhere ELSE — a Google Sheet, a CRM export, a Clay table — " +
601
- "in one call. Give the rows and Nous creates a lead list, resolves each row to a person/company " +
602
- "(deduped against everything already in the graph), and scores every one against the live ICP model " +
603
- "+ intent axis so the judgment lands in the graph for other agents. The spreadsheet stays the user's; " +
604
- "Nous just owns the score and keeps it fresh. Each row needs an email OR a LinkedIn URL. Rows we don't " +
605
- "know enough about yet come back `awaiting_enrichment` — run signal-scan / a lead-builder on them, then " +
606
- "re-attach. Pass `lead_list_id` instead of `name` to add to an existing list. Max 200 rows per call — " +
607
- "loop for a bigger sheet.",
608
- {
609
- name: z.string().optional().describe("Name for the new list (e.g. 'Q3 sheet — inbound'). Omit only when passing lead_list_id."),
610
- lead_list_id: z.string().optional().describe("Add to an existing list instead of creating one."),
611
- source: z.string().optional().describe("Where the list came from, e.g. 'google_sheet', 'crm_export' (default 'external')."),
612
- rows: z.array(z.object({
613
- email: z.string().optional(),
614
- linkedin_url: z.string().optional(),
615
- domain: z.string().optional(),
616
- company: z.string().optional(),
617
- name: z.string().optional(),
618
- }).passthrough()).describe("The list rows. Each needs an email or a linkedin_url."),
619
- import_duplicates: z.boolean().optional().describe("Force-insert rows already in this list (default false — deduped)."),
620
- },
621
- async ({ name, lead_list_id, source, rows, import_duplicates }) => {
622
- const r = await post("/v2/lead-lists/attach", { name, lead_list_id, source, rows, import_duplicates });
623
- const head = `Attached ${rows.length} rows to list ${r.lead_list_id} — ` +
624
- `${r.inserted} new, ${r.duplicate_skipped} already in list. ` +
625
- `Scored ${r.scored}; ${r.awaiting_enrichment} awaiting enrichment; ${r.unresolved} unresolved.`;
626
- const top = (r.results || [])
627
- .filter(x => x.scored)
628
- .sort((a, b) => (b.icp?.score ?? 0) - (a.icp?.score ?? 0))
629
- .slice(0, 10)
630
- .map(x => ` ${x.identifier} — ICP ${x.icp.score} (${(x.icp.tier || "").replace(/_/g, " ")}) · intent ${x.intent.score} ${x.intent.band}`);
631
- const tail = r.awaiting_enrichment ? `\n\n${r.awaiting_enrichment} rows need enrichment before they can score — run signal-scan or a lead-builder on the list, then re-attach.` : "";
632
- return { content: [{ type: "text", text: `${head}${top.length ? `\n\nTop scored:\n${top.join("\n")}` : ""}${tail}` }] };
633
- }
634
- );
635
-
636
630
  // ===========================================================================
637
631
  // TOOL: attention — GET /v2/attention
638
632
  // What to look at: accounts gone quiet, key facts decayed.
@@ -668,6 +662,70 @@ export function createServer() {
668
662
  }
669
663
  );
670
664
 
665
+ // ===========================================================================
666
+ // TOOL: campaign_performance — GET /v2/campaigns/performance
667
+ // The aggregate outbound feedback loop: which campaign/variant earns positive
668
+ // replies, and from good-fit accounts.
669
+ // ===========================================================================
670
+ server.tool(
671
+ "campaign_performance",
672
+ "Outbound campaign feedback loop: how each cold-email/LinkedIn campaign and sequence step is landing, " +
673
+ "sliced by reply sentiment and the ICP tier of who replied positively. Use to answer 'which campaign " +
674
+ "or variant gets the most positive replies?', 'is my best campaign landing on good-fit accounts?', or " +
675
+ "'which copy should I scale vs cut?'. positive_rate is positive replies / total replies (not / sent).",
676
+ {},
677
+ async () => {
678
+ const r = await get("/v2/campaigns/performance", {});
679
+ const camps = r.campaigns || [];
680
+ if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
681
+ const t = r.totals || {};
682
+ const pct = (n) => `${Math.round((n || 0) * 100)}%`;
683
+ const lines = camps.map(c => {
684
+ const tiers = c.tier_of_positive || {};
685
+ const good = (tiers.tier_1 || 0) + (tiers.tier_2 || 0);
686
+ const conv = `${c.conversions || 0} client${c.conversions === 1 ? "" : "s"}${c.median_days_to_client != null ? ` (avg ${c.median_days_to_client}d to close)` : ""}${c.in_progress ? `, ${c.in_progress} in evaluation` : ""}`;
687
+ const steps = (c.steps || []).map(s => ` step ${s.step ?? "—"}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`).join("\n");
688
+ return ` ${c.campaign_name || c.campaign_id}\n` +
689
+ ` ${c.replies} replies — ${c.positive} positive (${pct(c.positive_rate)}), ${c.neutral} neutral, ${c.negative} negative\n` +
690
+ ` converted: ${conv}\n` +
691
+ ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${c.positive}` +
692
+ (steps ? `\n${steps}` : "");
693
+ });
694
+ const head = `Campaign performance (${t.conversions || 0} clients, ${t.positive || 0}/${t.replies || 0} replies positive, ${pct(t.positive_rate)}):`;
695
+ return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
696
+ }
697
+ );
698
+
699
+ // ===========================================================================
700
+ // TOOL: pipeline_intelligence — GET /v2/pipeline/intelligence
701
+ // What converts, and how long it takes: stage distribution, stage-to-stage
702
+ // conversion %, median time-to-client, median time-in-stage.
703
+ // ===========================================================================
704
+ server.tool(
705
+ "pipeline_intelligence",
706
+ "How the pipeline actually converts: how many accounts sit at each stage right now, the stage-to-stage " +
707
+ "conversion rate (of everyone who reached a stage, how many reached the next — where deals leak), the " +
708
+ "median days to turn a lead into a client, and the median time spent in each stage. Use to answer 'how " +
709
+ "long does it take us to close?', 'where are deals stalling?', or 'what's my funnel look like?'.",
710
+ {},
711
+ async () => {
712
+ const r = await get("/v2/pipeline/intelligence", {});
713
+ const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
714
+ const cc = r.current_stage_counts || {};
715
+ const order = ["identified", "aware", "connected", "interested", "evaluating", "client", "lost", "disqualified", "churned"];
716
+ const dist = order.filter(s => cc[s]).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
717
+ const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
718
+ const dis = r.median_days_in_stage || {};
719
+ const inStage = order.filter(s => dis[s] != null).map(s => `${s} ${dis[s]}d`).join(" · ");
720
+ const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
721
+ ` now: ${dist}\n` +
722
+ ` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
723
+ ` stage-to-stage conversion:\n${conv || " (none yet)"}\n` +
724
+ (inStage ? ` median time in stage: ${inStage}` : "");
725
+ return { content: [{ type: "text", text }] };
726
+ }
727
+ );
728
+
671
729
  // ===========================================================================
672
730
  // TOOL: get_action_items — GET /v2/action-items
673
731
  // Commitments extracted from meetings/emails — what you owe each account.
@@ -741,63 +799,95 @@ export function createServer() {
741
799
  );
742
800
 
743
801
  // get_gtm_profile removed: the user's GTM lives in their files, mirrored into
744
- // the graph as playbooks (get_playbook) plus the learned ICP model. Read
745
- // get_playbook for the user's own rules, ICP, and positioning.
802
+ // the graph as foundations (get_foundation) plus the learned ICP model. Read
803
+ // get_foundation for the user's own rules, ICP, and positioning.
746
804
 
747
805
  // ===========================================================================
748
- // TOOLS: get_playbook / sync_playbook — the POLICY layer (vs. facts).
749
- // Playbooks are versioned rule-docs that GOVERN agent behavior: voice, outreach,
806
+ // TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
807
+ // Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
750
808
  // icp, positioning. Read the relevant one BEFORE acting; push file edits back so
751
- // every agent obeys the same rules. GET/POST /v2/playbooks.
809
+ // every agent obeys the same rules. GET/POST /v2/foundations.
752
810
  // ===========================================================================
753
- const getPlaybookSchema = {
811
+ const getFoundationSchema = {
754
812
  kind: z.enum(["voice", "outreach", "icp", "positioning"]).optional()
755
813
  .describe("Which policy to read. Omit to list all four."),
756
814
  };
757
- const getPlaybookHandler = async ({ kind }) => {
758
- const r = await get("/v2/playbooks", kind ? { kind } : undefined);
759
- const pbs = r.playbooks || [];
815
+ const getFoundationHandler = async ({ kind }) => {
816
+ const r = await get("/v2/foundations", kind ? { kind } : undefined);
817
+ const pbs = r.foundations || [];
760
818
  if (!pbs.length) return { content: [{ type: "text", text:
761
- "No playbooks set up yet. The user can set them up on the Playbooks page or in their context files." }] };
819
+ "No foundations set up yet. The user can set them up on the Foundations page or in their context files." }] };
762
820
  if (kind) {
763
821
  const pb = pbs[0];
764
822
  const src = pb.source === "claude_code" ? `mirrors ${pb.file_path}` : "stored in Nous";
765
823
  return { content: [{ type: "text", text:
766
- `# ${pb.title} — ${pb.kind} playbook (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
824
+ `# ${pb.title} — ${pb.kind} foundation (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
767
825
  }
768
826
  const lines = pbs.map(p => ` ${p.kind.padEnd(12)} ${p.title} (${p.source === "claude_code" ? p.file_path : "stored in Nous"})`);
769
827
  return { content: [{ type: "text", text:
770
- "The user's playbooks (read one with get_playbook(kind)):\n" + lines.join("\n") }] };
828
+ "The user's foundations (read one with get_foundation(kind)):\n" + lines.join("\n") }] };
771
829
  };
772
- server.tool("get_playbook",
773
- "Read a PLAYBOOK — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
774
- "These are RULES TO OBEY, not facts. Read the relevant playbook BEFORE you act: before writing outreach " +
830
+ server.tool("get_foundation",
831
+ "Read a FOUNDATION — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
832
+ "These are RULES TO OBEY, not facts. Read the relevant foundation BEFORE you act: before writing outreach " +
775
833
  "read 'voice' and 'outreach'; before scoring or qualifying read 'icp'; for messaging read 'positioning'. " +
776
834
  "Omit kind to list all four.",
777
- getPlaybookSchema, getPlaybookHandler);
835
+ getFoundationSchema, getFoundationHandler);
778
836
 
779
- const syncPlaybookSchema = {
780
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which playbook to update."),
781
- body_md: z.string().describe("The full markdown content of the playbook. Follow the Nous document house style so every playbook reads like a clean text file: a '# Title' line, a '> ' one-paragraph lede, an optional plain 'Key: value' block, a '---' divider, then '## Title-case' sections with plain '- ' bullets. Keep it markdown, no decorative formatting."),
837
+ const syncFoundationSchema = {
838
+ kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which foundation to update."),
839
+ body_md: z.string().describe("The full markdown content of the foundation. Follow the Nous document house style so every foundation reads like a clean text file: a '# Title' line, a '> ' one-paragraph lede, an optional plain 'Key: value' block, a '---' divider, then '## Title-case' sections with plain '- ' bullets. Keep it markdown, no decorative formatting."),
782
840
  file_path: z.string().optional().describe("The repo file this mirrors, e.g. 'context/icp/icp.md'. Pass it when syncing a Claude Code file so the source is recorded as the file."),
783
841
  };
784
- const syncPlaybookHandler = async ({ kind, body_md, file_path }) => {
785
- const r = await post(`/v2/playbooks/${kind}`, { body_md, file_path });
842
+ const syncFoundationHandler = async ({ kind, body_md, file_path }) => {
843
+ const r = await post(`/v2/foundations/${kind}`, { body_md, file_path });
786
844
  return { content: [{ type: "text", text:
787
- `Synced the ${r.playbook?.kind || kind} playbook into Nous (v${r.playbook?.version}). Other agents now read the same rules.` }] };
845
+ `Synced the ${r.foundation?.kind || kind} foundation into Nous (v${r.foundation?.version}). Other agents now read the same rules.` }] };
788
846
  };
789
- server.tool("sync_playbook",
790
- "Push a playbook's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
847
+ server.tool("sync_foundation",
848
+ "Push a foundation's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
791
849
  "whenever you edit a policy file in the repo (e.g. references/voice.md, outreach rules), passing the " +
792
850
  "file's new content and its path, so Nous mirrors it and every other agent obeys the same rules. An " +
793
- "edited playbook file that isn't synced is silently inert — other agents keep reading the old rules. " +
794
- "MIRROR, DO NOT REWRITE: when the user already has a playbook file, sync it AS-IS. Their file is the " +
851
+ "edited foundation file that isn't synced is silently inert — other agents keep reading the old rules. " +
852
+ "MIRROR, DO NOT REWRITE: when the user already has a foundation file, sync it AS-IS. Their file is the " +
795
853
  "author and Nous is the mirror — always pass file_path so the next sync knows where an in-app edit " +
796
854
  "lands. 'Improving' their wording on the way through means the copy in Nous silently disagrees with " +
797
855
  "the copy in their repo, and they will trust neither. If a file looks wrong, SAY SO; don't fix it in " +
798
856
  "transit. " +
799
857
  "(For the ICP/context files specifically, sync_icp is the sync — use that one.)",
800
- syncPlaybookSchema, syncPlaybookHandler);
858
+ syncFoundationSchema, syncFoundationHandler);
859
+
860
+ // ===========================================================================
861
+ // TOOL: get_insights — what Nous LEARNED about us from calls (the mirror of
862
+ // foundations). Insights are auto-extracted from call transcripts into four docs:
863
+ // product, positioning, market, buyer. READ-ONLY over MCP — the extractor
864
+ // authors them, not agents. GET /v2/insights[?category=].
865
+ // ===========================================================================
866
+ const getInsightsSchema = {
867
+ category: z.enum(["product", "positioning", "market", "buyer"]).optional()
868
+ .describe("Which insight doc to read. Omit to list all four."),
869
+ };
870
+ const getInsightsHandler = async ({ category }) => {
871
+ const r = await get("/v2/insights", category ? { category } : undefined);
872
+ const docs = r.insights || [];
873
+ if (!docs.length) return { content: [{ type: "text", text:
874
+ "No insights captured yet. They fill automatically from call transcripts (product, positioning, market, buyer)." }] };
875
+ if (category) {
876
+ const d = docs[0];
877
+ return { content: [{ type: "text", text:
878
+ `# ${d.title} insights (v${d.version})\n\n${d.body_md || "(empty)"}` }] };
879
+ }
880
+ const lines = docs.map(d => ` ${d.category.padEnd(12)} ${d.title} (v${d.version})`);
881
+ return { content: [{ type: "text", text:
882
+ "What Nous learned about us from calls (read one with get_insights(category)):\n" + lines.join("\n") }] };
883
+ };
884
+ server.tool("get_insights",
885
+ "Read INSIGHTS — what Nous learned about US from call transcripts, the mirror of the foundations/foundations " +
886
+ "the user authors. Four docs: product (what to build), positioning (how to message), market (segments, " +
887
+ "wedges, channels), buyer (ICP, the pain that drives the purchase). These accumulate automatically after " +
888
+ "every call. Read them when working on product direction, messaging, GTM strategy, or targeting. Omit " +
889
+ "category to list all four.",
890
+ getInsightsSchema, getInsightsHandler);
801
891
 
802
892
  // The GTM context is no longer written through a dedicated MCP tool. In the file
803
893
  // symbiosis model the user's own files (context/icp.md, positioning.md, …) are
@@ -878,7 +968,7 @@ export function createServer() {
878
968
  // ===========================================================================
879
969
  // TOOL: get_workspace_status — GET /v2/workspace/status
880
970
  // The "one main call." Nous is operated by the agent, so the agent needs to
881
- // know the state of the workspace: is it onboarded, is the GTM playbook built,
971
+ // know the state of the workspace: is it onboarded, is the GTM foundation built,
882
972
  // which integrations are connected, is CRM sync configured, are events live —
883
973
  // and what to set up next. Call this at the start of a session.
884
974
  // ===========================================================================
@@ -926,7 +1016,7 @@ export function createServer() {
926
1016
  : " — MISSING. The workspace is not set up until this exists. Scan their repo before you ask them anything."}`
927
1017
  );
928
1018
  lines.push(` ${mark(setup.onboarding?.done)} Profile${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
929
- lines.push(` ${mark(setup.gtm_playbook?.done)} GTM playbook${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
1019
+ lines.push(` ${mark(setup.gtm_playbook?.done)} GTM foundation${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
930
1020
  if (setup.icp_sync) {
931
1021
  const sy = setup.icp_sync;
932
1022
  lines.push(` ⟳ ICP synced from ${sy.synced_from} (${relAge(sy.synced_at)})${sy.model_changed ? " · model has CHANGED since — run export_icp_model to refresh the file" : ""}`);
@@ -1016,15 +1106,15 @@ export function createServer() {
1016
1106
 
1017
1107
  // ===========================================================================
1018
1108
  // TOOL: build_icp_model — POST /v2/workspace/scoring-model
1019
- // The second half of building the GTM playbook. The agent syncs the GTM context
1109
+ // The second half of building the GTM foundation. The agent syncs the GTM context
1020
1110
  // from the user's files with sync_icp, then calls this to turn it into a weighted
1021
1111
  // ICP scoring model. After this, accounts get scored for fit and
1022
- // get_workspace_status shows the playbook as done.
1112
+ // get_workspace_status shows the foundation as done.
1023
1113
  // ===========================================================================
1024
1114
  server.tool(
1025
1115
  "build_icp_model",
1026
1116
  "Build (or rebuild) the user's ICP scoring model from their synced GTM context. This is " +
1027
- "the second half of setting up the GTM playbook: first sync the user's ICP/positioning/pricing " +
1117
+ "the second half of setting up the GTM foundation: first sync the user's ICP/positioning/pricing " +
1028
1118
  "files with sync_icp, then call this to translate that context into a weighted set of scoring " +
1029
1119
  "signals so accounts get scored for fit. (sync_icp usually builds the model on first sync, so you " +
1030
1120
  "often won't need this directly.) If a model already exists it is left alone unless you " +
@@ -1072,7 +1162,7 @@ export function createServer() {
1072
1162
  "customer domains and closed-LOST domains; Nous enriches each, links the contacts you already " +
1073
1163
  "have there, and runs contrastive lift (what's true of winners but not losers) to discover the " +
1074
1164
  "signals that actually predict revenue — then re-scores open accounts. This is the strongest way " +
1075
- "to build the playbook: a model trained on who actually bought beats one inferred from a " +
1165
+ "to build the foundation: a model trained on who actually bought beats one inferred from a " +
1076
1166
  "description. Ask the user for a handful of each (even 3-5 won + 3-5 lost helps). Domains only " +
1077
1167
  "(e.g. 'acme.com'), no scheme.",
1078
1168
  {
@@ -1341,300 +1431,6 @@ export function createServer() {
1341
1431
  }
1342
1432
  );
1343
1433
 
1344
- // ===========================================================================
1345
- // TOOL: set_trigger / list_triggers — /v2/workspace/triggers
1346
- // Outbound event triggers (webhooks) — wire the user's stack to fire when the
1347
- // record changes.
1348
- // ===========================================================================
1349
- server.tool(
1350
- "set_trigger",
1351
- "Create an outbound event trigger (a webhook) so an external tool is notified when something " +
1352
- "happens in the workspace — e.g. a new contact, a reply, a meeting booked. Pass the destination " +
1353
- "URL and which events to fire on. Call list_triggers first to see the available event names.",
1354
- {
1355
- url: z.string().describe("The destination URL the event is POSTed to."),
1356
- events: z.array(z.string()).describe("Event names to fire on (see list_triggers for the catalog)."),
1357
- name: z.string().optional().describe("Optional label for the trigger."),
1358
- },
1359
- async ({ url, events, name }) => {
1360
- try {
1361
- const r = await post("/v2/workspace/triggers", { url, events, name });
1362
- return { content: [{ type: "text", text: `Trigger created for ${events.join(", ")} → ${url}.` }] };
1363
- } catch (e) {
1364
- const msg = String(e?.message ?? e);
1365
- return { content: [{ type: "text", text: `Couldn't create the trigger: ${msg}. Call list_triggers to see valid event names.` }] };
1366
- }
1367
- }
1368
- );
1369
- server.tool(
1370
- "list_triggers",
1371
- "List the workspace's outbound event triggers (webhooks) and the catalog of available event names.",
1372
- {},
1373
- async () => {
1374
- const r = await get("/v2/workspace/triggers");
1375
- const lines = [];
1376
- if (r.triggers?.length) {
1377
- lines.push(`TRIGGERS (${r.triggers.length}):`);
1378
- for (const t of r.triggers) lines.push(` ${t.name || "(unnamed)"} → ${t.url} [${(t.events || []).join(", ")}]`);
1379
- } else {
1380
- lines.push("No triggers set up yet.");
1381
- }
1382
- if (r.available_events?.length) {
1383
- lines.push("", `AVAILABLE EVENTS: ${r.available_events.join(", ")}`);
1384
- }
1385
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1386
- }
1387
- );
1388
-
1389
- // ===========================================================================
1390
- // TOOL: lead_list_operations — GET /api/lead-lists[/:id/operations]
1391
- // The operations trail for a lead list: imports, enrichment runs, pushes to
1392
- // campaigns, and replies — filterable by category and time window. This is how
1393
- // you answer "what happened on this list?" and attribute campaign performance
1394
- // back to where the leads came from (the list's source). Call with no
1395
- // lead_list_id to discover the lists and their ids first.
1396
- // ===========================================================================
1397
- server.tool(
1398
- "lead_list_operations",
1399
- "(Nous Cloud only) Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
1400
- "and classified replies — to report on what happened and attribute outcomes to a list's source. " +
1401
- "Call with NO lead_list_id to list the workspace's lead lists (id, name, count, source), then " +
1402
- "call again with an id. Filter with `event` (import | enrich | export | reply) and `days`. " +
1403
- "Each operation is a run-level summary (one row per import/enrich/push), not per-lead noise.",
1404
- {
1405
- lead_list_id: z.string().optional().describe("The lead list's UUID. Omit to list the available lead lists first."),
1406
- event: z.enum(["import", "enrich", "export", "reply"]).optional().describe("Filter to one category of operation."),
1407
- days: z.number().optional().describe("Look back this many days (default 30). Pass a large number for all-time."),
1408
- limit: z.number().optional().describe("Max operations to return (default 100, cap 200)."),
1409
- },
1410
- async ({ lead_list_id, event, days, limit }) => {
1411
- // Discovery mode — no list id yet. Return the lists so the agent can pick.
1412
- if (!lead_list_id) {
1413
- const r = await get("/api/lead-lists");
1414
- const lists = r.lead_lists || [];
1415
- const lines = lists.length
1416
- ? [`LEAD LISTS (${lists.length}):`,
1417
- ...lists.map(l => ` ${l.id} ${l.name} · ${l.lead_count ?? 0} leads · source: ${l.source || "—"}`),
1418
- "", "Call lead_list_operations again with one of these ids (and an optional event filter)."]
1419
- : ["No lead lists yet."];
1420
- return { content: [{ type: "text", text: lines.join("\n") }] };
1421
- }
1422
-
1423
- const r = await get(`/api/lead-lists/${encodeURIComponent(lead_list_id)}/operations`, { event, days, limit });
1424
- const ops = r.operations || [];
1425
- const lines = [];
1426
- const summary = Object.entries(r.by_category || {}).map(([k, v]) => `${k} ${v}`).join(" · ");
1427
- lines.push(`OPERATIONS${event ? ` · ${event}` : ""} (${ops.length})${summary ? ` — ${summary}` : ""}`);
1428
- if (!ops.length) {
1429
- lines.push("", "No operations in this window.");
1430
- } else {
1431
- for (const o of ops) {
1432
- const cat = o.metadata?.category || o.event_type;
1433
- lines.push(` ${relAge(o.occurred_at).padEnd(8)} ${String(cat).padEnd(8)} ${o.summary}`);
1434
- }
1435
- }
1436
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1437
- }
1438
- );
1439
-
1440
- // ===========================================================================
1441
- // TOOL: get_coverage — POST /v2/dedup (exact) | GET /v2/people/coverage (estimate)
1442
- // "What do I already have?" before spending on a list elsewhere. One tool, two
1443
- // modes: pass identifiers for an EXACT per-lead net-new/re-enrich/reuse check
1444
- // (the pre-spend gate), or a title/keyword for a rough attribute ESTIMATE.
1445
- // (Replaces the former check_leads + lead_coverage tools.)
1446
- // ===========================================================================
1447
- server.tool(
1448
- "get_coverage",
1449
- "(Nous Cloud only) Check what you ALREADY have before spending on a list elsewhere (Apollo, Sales Navigator, Clay). " +
1450
- "Two modes:\n" +
1451
- " • EXACT — pass candidate identifiers (emails / linkedin_urls / domains, free in any tool's " +
1452
- "preview). Returns per-lead buckets: net_new (acquire + enrich), needs_enrichment (you OWN these " +
1453
- "but stale >90d — re-enrich, don't re-buy), reusable (fresh verified email on file — reuse, spend " +
1454
- "nothing), plus engaged/recent/known/bounced to skip. Each result carries entity_id, email_status, " +
1455
- "enriched_at, stale.\n" +
1456
- " • ESTIMATE — pass a title and/or keyword instead. Returns how many people you already have " +
1457
- "matching (e.g. title='founder', keyword='agency'), split by freshness: never-enriched, stale >90d, " +
1458
- "fresh-verified. Rough by design (title precise; keyword matches title/company/department).\n" +
1459
- "Pass identifiers for the exact pre-spend check, OR title/keyword for the planning estimate — not both.",
1460
- {
1461
- emails: z.array(z.string()).optional().describe("EXACT mode — candidate email addresses (up to 50,000)."),
1462
- linkedin_urls: z.array(z.string()).optional().describe("EXACT mode — candidate LinkedIn profile URLs (up to 50,000)."),
1463
- domains: z.array(z.string()).optional().describe("EXACT mode — company domains, 'do I already have anyone here?' (up to 50,000)."),
1464
- title: z.string().optional().describe("ESTIMATE mode — role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
1465
- keyword: z.string().optional().describe("ESTIMATE mode — extra match across title/company/department, e.g. 'agency'."),
1466
- stale_days: z.number().optional().describe("ESTIMATE mode — days after which enrichment counts as stale (default 90)."),
1467
- },
1468
- async ({ emails, linkedin_urls, domains, title, keyword, stale_days }) => {
1469
- const hasIds = !!(emails?.length || linkedin_urls?.length || domains?.length);
1470
- const hasAttr = !!(title || keyword);
1471
- if (hasIds && hasAttr) {
1472
- return { content: [{ type: "text", text:
1473
- "Pass identifiers (emails/linkedin_urls/domains) for the exact check, OR title/keyword for the estimate — not both." }] };
1474
- }
1475
-
1476
- // EXACT mode — per-identifier coverage against /v2/dedup.
1477
- if (hasIds) {
1478
- const body = {};
1479
- if (emails?.length) body.emails = emails;
1480
- if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
1481
- if (domains?.length) body.domains = domains;
1482
- const r = await post("/v2/dedup", body);
1483
- const s = r.summary || {};
1484
- const lines = [
1485
- `COVERAGE (${s.total ?? 0} checked)`,
1486
- ` net_new ${s.net_new ?? 0} → acquire + enrich`,
1487
- ` needs_enrichment ${s.needs_enrichment ?? 0} → you OWN these but stale (>90d) → re-enrich, don't re-buy`,
1488
- ` reusable ${s.reusable ?? 0} → fresh verified email on file → reuse, spend nothing`,
1489
- ` engaged ${s.engaged ?? 0} → in an active conversation, don't cold-send`,
1490
- ` recent ${s.recent ?? 0} → contacted <30d, defer`,
1491
- ` known ${s.known ?? 0} → company already in the workspace`,
1492
- ` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
1493
- ];
1494
- // Surface a few stale entities the caller should re-enrich (with their last date).
1495
- const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
1496
- if (stale.length) {
1497
- lines.push("", "RE-ENRICH (sample):");
1498
- for (const x of stale) {
1499
- lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
1500
- }
1501
- }
1502
- return { content: [{ type: "text", text: lines.join("\n") }] };
1503
- }
1504
-
1505
- // ESTIMATE mode — attribute coverage against /v2/people/coverage.
1506
- if (hasAttr) {
1507
- const r = await get("/v2/people/coverage", { title, keyword, stale_days });
1508
- const lines = [
1509
- `COVERAGE — ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
1510
- ` ${r.total ?? 0} already in your workspace`,
1511
- ` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
1512
- ` ${r.fresh_verified ?? 0} have a fresh verified email`,
1513
- ];
1514
- const sample = r.sample || [];
1515
- if (sample.length) {
1516
- lines.push("", "SAMPLE (oldest first):");
1517
- for (const s of sample.slice(0, 12)) {
1518
- lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
1519
- }
1520
- }
1521
- return { content: [{ type: "text", text: lines.join("\n") }] };
1522
- }
1523
-
1524
- return { content: [{ type: "text", text:
1525
- "Pass at least one of: emails / linkedin_urls / domains (exact check), or title / keyword (estimate)." }] };
1526
- }
1527
- );
1528
-
1529
- // ===========================================================================
1530
- // TOOLS: enrich_leads / verify_leads — POST /api/lead-lists/:id/enrich|verify
1531
- // The agent OPERATES the lead list. Both are two-step: a dry-run preview that
1532
- // quotes the chargeable count + provider + $ estimate (report it to the user
1533
- // first), then a confirmed run as a background job. Target by `filter` so no
1534
- // ids are needed (enrich {emailStatus:'none'} = all missing an email; verify
1535
- // defaults to all unverified). BYOK — the $ is the user's own provider spend.
1536
- // ===========================================================================
1537
- const fmtCost = (c) => {
1538
- if (!c) return "no chargeable records — nothing to spend";
1539
- const money = c.low === c.high ? `~$${c.low.toFixed(2)}` : `~$${c.low.toFixed(2)}–$${c.high.toFixed(2)}`;
1540
- return `${money} via ${c.label} (${(c.count ?? 0).toLocaleString()} ${c.action})`;
1541
- };
1542
- const LEAD_FILTER_SHAPE = {
1543
- emailStatus: z.enum(["has", "none", "unverified"]).optional().describe("none = no email yet; unverified = has an email but no verification verdict; has = has any email."),
1544
- domain: z.enum(["has", "none"]).optional().describe("has = a company domain is known; none = no domain."),
1545
- icp: z.enum(["true", "false"]).optional().describe("true = ICP-qualified leads only."),
1546
- status: z.string().optional().describe("Lifecycle: pending | sent | replied | bounced."),
1547
- source: z.string().optional().describe("Substring of where the lead came from (campaign / import name)."),
1548
- size: z.string().optional().describe("Substring of company size, e.g. '1 to 10'."),
1549
- channel: z.string().optional().describe("Last-contacted channel substring, or 'none' for not-yet-contacted."),
1550
- };
1551
-
1552
- server.tool(
1553
- "enrich_leads",
1554
- "(Nous Cloud only) Find missing emails for leads in a lead list, on the workspace's own Prospeo/Apollo key. ALWAYS two " +
1555
- "steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, provider, $ estimate) — " +
1556
- "report it and get the user's go-ahead — then call again with confirm:true to run as a background job. " +
1557
- "Pick leads with `filter` (e.g. {emailStatus:'none'} = every lead missing an email, the usual case) or " +
1558
- "explicit `ids`; defaults to {emailStatus:'none'}. Call lead_list_operations with no id first to get the " +
1559
- "list's id.",
1560
- {
1561
- lead_list_id: z.string().describe("The lead list's UUID."),
1562
- filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all leads missing an email."),
1563
- ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
1564
- confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only (spends nothing). true = actually run it as a background job."),
1565
- },
1566
- async ({ lead_list_id, filter, ids, confirm }) => {
1567
- const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "none" } };
1568
- const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/enrich`;
1569
- try {
1570
- if (!confirm) {
1571
- const r = await post(path, { ...sel, preview: true });
1572
- const lines = [
1573
- `ENRICH PREVIEW — list ${lead_list_id}`,
1574
- ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} already on file (free) · ${r.no_identifier ?? 0} no identifier`,
1575
- ` provider: ${r.provider || "—"}`,
1576
- ` estimated cost: ${fmtCost(r.cost)}`,
1577
- "",
1578
- r.chargeable
1579
- ? "Report this to the user. To run it, call enrich_leads again with the same selection and confirm:true."
1580
- : "Nothing chargeable to enrich.",
1581
- ];
1582
- return { content: [{ type: "text", text: lines.join("\n") }] };
1583
- }
1584
- const r = await post(path, { ...sel, background: true });
1585
- return { content: [{ type: "text", text:
1586
- `Enrichment started — job ${r.job_id}, ${r.total} lead${r.total === 1 ? "" : "s"} queued. It runs in the background; report back to the user that it's running.` }] };
1587
- } catch (e) {
1588
- return { content: [{ type: "text", text: `Couldn't enrich: ${e.message}` }] };
1589
- }
1590
- }
1591
- );
1592
-
1593
- server.tool(
1594
- "verify_leads",
1595
- "(Nous Cloud only) Validate email deliverability for leads in a lead list, on the workspace's own MillionVerifier / " +
1596
- "NeverBounce key. ALWAYS two steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, " +
1597
- "connected verifiers, $ estimate) — report it to the user — then call again with confirm:true to run as " +
1598
- "a background job. Defaults to every UNVERIFIED email (has an address, no verdict yet); narrow with " +
1599
- "`filter` or pass `ids`. If no verifier is connected it says so — tell the user to add a MillionVerifier " +
1600
- "or NeverBounce key in Integrations.",
1601
- {
1602
- lead_list_id: z.string().describe("The lead list's UUID."),
1603
- filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all unverified emails."),
1604
- ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
1605
- provider: z.enum(["millionverifier", "neverbounce"]).optional().describe("Which verifier to use. Defaults to MillionVerifier, then NeverBounce."),
1606
- confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only. true = actually run it as a background job."),
1607
- },
1608
- async ({ lead_list_id, filter, ids, provider, confirm }) => {
1609
- const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "unverified" } };
1610
- const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/verify`;
1611
- try {
1612
- if (!confirm) {
1613
- const r = await post(path, { ...sel, provider, preview: true });
1614
- const lines = [
1615
- `VERIFY PREVIEW — list ${lead_list_id}`,
1616
- ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} recently verified (free) · ${r.no_email ?? 0} no email`,
1617
- ` verifier: ${r.provider || "—"}${r.connected_verifiers ? ` (connected: ${r.connected_verifiers.join(", ") || "none"})` : ""}`,
1618
- ` estimated cost: ${fmtCost(r.cost)}`,
1619
- "",
1620
- r.chargeable
1621
- ? "Report this to the user. To run it, call verify_leads again with the same selection and confirm:true."
1622
- : "Nothing chargeable to verify.",
1623
- ];
1624
- return { content: [{ type: "text", text: lines.join("\n") }] };
1625
- }
1626
- const r = await post(path, { ...sel, provider, background: true });
1627
- return { content: [{ type: "text", text:
1628
- `Verification started — job ${r.job_id}, ${r.total} email${r.total === 1 ? "" : "s"} queued via ${r.provider}. It runs in the background; report back to the user.` }] };
1629
- } catch (e) {
1630
- const msg = /no_verifier_connected/.test(e.message)
1631
- ? "No email verifier is connected. Tell the user to add a MillionVerifier or NeverBounce API key in Integrations, then try again."
1632
- : `Couldn't verify: ${e.message}`;
1633
- return { content: [{ type: "text", text: msg }] };
1634
- }
1635
- }
1636
- );
1637
-
1638
1434
  // ===========================================================================
1639
1435
  // TOOL: scrape_engagers
1640
1436
  // On-demand LinkedIn engager scrape — mine who commented/reacted on the