@opennous/mcp 0.27.0 → 0.29.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 +322 -108
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Nous MCP Server — Customer graph for GTM agents.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
package/src/server.js CHANGED
@@ -27,19 +27,21 @@
27
27
  * record_closed_deals — build the ICP model from real closed-won/lost deals (contrastive lift)
28
28
  * connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
29
29
  * configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
30
+ * sync_crm_now — run an immediate incremental/full CRM pull (don't wait for the daily cron)
30
31
  * set_trigger — create an outbound event trigger (webhook); list_triggers reads them
31
32
  * list_triggers — list the workspace's event triggers + available events
32
33
  * get_routing_preferences — Claude Code routing prefs to default GTM to Nous (write to CLAUDE.md)
33
34
  * lead_list_operations — the operations trail of a lead list (imports/enrich/push/replies), filterable
34
- * check_leads — pre-spend coverage check: which candidates you already own / should re-enrich
35
- * lead_coverage attribute coverage estimate ("how many agency founders do we have, by freshness")
35
+ * coverage — pre-spend coverage: exact per-lead check (identifiers) or attribute estimate (title/keyword)
36
+ * enrich_leads find missing emails for a lead list (two-step: dry-run cost preview, then confirm)
37
+ * verify_leads — validate email deliverability for a lead list (two-step preview, then confirm)
36
38
  */
37
39
 
38
40
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
39
41
  import { z } from "zod";
40
42
  import { get, post } from "./client.js";
41
43
 
42
- export const SERVER_VERSION = "0.27.0";
44
+ export const SERVER_VERSION = "0.34.0";
43
45
 
44
46
  // ─── helpers ──────────────────────────────────────────────────────────────────
45
47
 
@@ -54,6 +56,28 @@ function relAge(ts) {
54
56
  return `${Math.floor(m / 12)}y ago`;
55
57
  }
56
58
 
59
+ // Absolute calendar date + clock time, in the user's local zone (this server runs
60
+ // on their machine over stdio, so toLocaleString is already local). For meetings,
61
+ // "Tue, Jun 16, 3:00 PM" beats relAge's fuzzy "today" — and relAge can't represent
62
+ // the future at all, so every scheduled call would otherwise read "today".
63
+ function fmtWhen(ts) {
64
+ if (!ts) return "—";
65
+ return new Date(ts).toLocaleString("en-US", {
66
+ weekday: "short", month: "short", day: "numeric",
67
+ hour: "numeric", minute: "2-digit", timeZoneName: "short",
68
+ });
69
+ }
70
+
71
+ // When to show an absolute datetime vs a relative age. Meetings/calls always get
72
+ // the exact time (you need to know it's 3pm, not "today"); so does anything
73
+ // future-dated (a scheduled event), which relAge would collapse to "today".
74
+ function whenLabel(type, ts) {
75
+ const t = String(type || "");
76
+ const isMeeting = t.includes("meeting") || t.includes("call");
77
+ const isFuture = ts && new Date(ts).getTime() > Date.now();
78
+ return (isMeeting || isFuture) ? fmtWhen(ts) : relAge(ts);
79
+ }
80
+
57
81
  const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
58
82
  const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
59
83
  const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
@@ -166,7 +190,7 @@ export function createServer() {
166
190
  lines.push("TIMELINE:");
167
191
  for (const t of ctx.timeline) {
168
192
  if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
169
- else lines.push(` ${relAge(t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
193
+ else lines.push(` ${whenLabel(t.type, t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
170
194
  }
171
195
  lines.push("");
172
196
  }
@@ -229,7 +253,7 @@ export function createServer() {
229
253
  if (obs.length) {
230
254
  lines.push(`TIMELINE (${obs.length}):`);
231
255
  for (const o of obs.slice(0, 30)) {
232
- lines.push(` ${relAge(o.observed_at)} ${fmtType(o.property)}`);
256
+ lines.push(` ${whenLabel(o.property, o.observed_at)} ${fmtType(o.property)}`);
233
257
  }
234
258
  }
235
259
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
@@ -269,6 +293,50 @@ export function createServer() {
269
293
  }
270
294
  );
271
295
 
296
+ // ===========================================================================
297
+ // TOOL: record_signal — a buying signal, as a structured signal.<class> fact
298
+ // A validated wrapper over record: one canonical way to write a signal, so it
299
+ // both shows on the account's Signals tab AND feeds the ICP scorecard as a
300
+ // feature (signal.* claims flow into the feature map the scorer reads).
301
+ // ===========================================================================
302
+ server.tool(
303
+ "record_signal",
304
+ "Record a buying signal on a person or company — a concrete, current reason to reach out, " +
305
+ "found by research (signal-scan). Stored as a structured signal.<class> fact so it shows on the " +
306
+ "account's Signals tab AND feeds the ICP scoring model as a feature. One call per signal; one " +
307
+ "current signal per class (the strongest). class is one of stack | hiring | momentum | friction | " +
308
+ "intent | domain. score is 0-10 (exclusivity x intent — score honestly, a 4 is useful). Be " +
309
+ "specific: 'posted 3 SDR roles in 30 days', not 'they're growing'.",
310
+ {
311
+ focus: z.string().describe("Email address or entity UUID of the person/company"),
312
+ signal_class: z.enum(["stack", "hiring", "momentum", "friction", "intent", "domain"])
313
+ .describe("the signal class"),
314
+ detected: z.string().describe("the specific, factual finding"),
315
+ implies: z.string().optional().describe("what the prospect is likely experiencing because of it"),
316
+ score: z.number().min(0).max(10).describe("strength 0-10 (exclusivity x intent)"),
317
+ approach: z.enum(["pain_led", "value_led", "fallback"]).optional()
318
+ .describe("recommended outreach approach"),
319
+ angle: z.string().optional().describe("one-line outreach angle this signal enables"),
320
+ },
321
+ async ({ focus, signal_class, detected, implies, score, approach, angle }) => {
322
+ const result = await post("/v2/observations", {
323
+ focus,
324
+ observations: [{
325
+ kind: "state",
326
+ property: `signal.${signal_class}`,
327
+ value: { detected, implies: implies ?? null, score, approach: approach ?? null, angle: angle ?? null },
328
+ source: "signal-scan",
329
+ }],
330
+ });
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: `Recorded ${signal_class} signal (score ${score}/10) on ${result.entity_id || focus}.`,
335
+ }],
336
+ };
337
+ }
338
+ );
339
+
272
340
  // ===========================================================================
273
341
  // TOOL: query — POST /v2/query
274
342
  // Retrieve a corpus of activity across many people. You do the analysis.
@@ -282,14 +350,21 @@ export function createServer() {
282
350
  " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
283
351
  "'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
284
352
  " 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
285
- "(use scope.property='stage' for funnel reports).",
353
+ "(use scope.property='stage' for funnel reports).\n" +
354
+ " 4. Scheduled meetings/calls are events with property 'interaction.meeting_scheduled' and a " +
355
+ "future-dated `when`. For 'what's booked today/this week', set property:'interaction.meeting_scheduled' " +
356
+ "with from/to bounding the day or week (since_days only looks backward and can't reach them), and " +
357
+ "order:'asc' to list soonest-first. Meeting rows render the absolute date and time.",
286
358
  {
287
359
  scope: z.object({
288
360
  kind: z.enum(["event", "state"]).optional(),
289
- property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied"),
361
+ property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied; 'interaction.meeting_scheduled' for booked meetings"),
290
362
  source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
291
363
  entity_id: z.string().optional().describe("scope to one person/company"),
292
- since_days: z.number().optional().describe("only activity within the last N days"),
364
+ since_days: z.number().optional().describe("only activity within the last N days (backward only)"),
365
+ from: z.string().optional().describe("ISO timestamp — only activity at/after this (absolute lower bound; use for date windows like 'today')"),
366
+ to: z.string().optional().describe("ISO timestamp — only activity at/before this (absolute upper bound). Combine from+to for a window; future-dated for upcoming meetings"),
367
+ order: z.enum(["asc", "desc"]).optional().describe("observed_at order (default desc, newest first). Use 'asc' for an upcoming-meeting schedule (soonest first)"),
293
368
  limit: z.number().optional().describe("max items (default 50, cap 200)"),
294
369
  }).describe("Corpus filter"),
295
370
  without: z.object({
@@ -321,11 +396,11 @@ export function createServer() {
321
396
  for (const it of r.items ?? []) {
322
397
  if (r.return === "entities") {
323
398
  lines.push(` ${it.entity_name ?? it.entity_id} ` +
324
- `(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${relAge(it.most_recent_at)})` +
399
+ `(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${whenLabel(it.most_recent_type, it.most_recent_at)})` +
325
400
  (it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
326
401
  (it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
327
402
  } else {
328
- lines.push(` ${relAge(it.when)} ${it.entity_name ?? it.entity_id} ` +
403
+ lines.push(` ${whenLabel(it.type, it.when)} ${it.entity_name ?? it.entity_id} ` +
329
404
  `${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
330
405
  }
331
406
  }
@@ -339,9 +414,12 @@ export function createServer() {
339
414
  // ===========================================================================
340
415
  server.tool(
341
416
  "attention",
342
- "What needs your attention across the workspace right now — accounts that have gone quiet and " +
343
- "key facts that have decayed. Returns ranked items, each with what happened and a suggested " +
344
- "action. Call this to decide who to work next.",
417
+ "What needs your attention across the workspace right now — upcoming meetings and calls in the " +
418
+ "next 7 days (each with its date and time, soonest first), accounts that have gone quiet, and key " +
419
+ "facts that have decayed. Returns ranked items (time-critical meetings lead), each with what's " +
420
+ "happening and a suggested action. Call this to decide what to work next, or to answer 'what's " +
421
+ "coming up' / 'what's on my calendar this week'. For a precise single-day list, use query with " +
422
+ "property:'interaction.meeting_scheduled' and from/to.",
345
423
  {
346
424
  limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
347
425
  },
@@ -350,8 +428,11 @@ export function createServer() {
350
428
  if (!r.items?.length) {
351
429
  return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
352
430
  }
353
- const lines = r.items.map(it =>
354
- ` ${it.entity_name ?? it.entity_id} ${it.what}\n → ${it.suggested_action}`);
431
+ // Upcoming meetings carry a `when` — render the absolute local date+time.
432
+ const lines = r.items.map(it => {
433
+ const when = it.when ? `${fmtWhen(it.when)} — ` : "";
434
+ return ` ${when}${it.entity_name ?? it.entity_id} — ${it.what}\n → ${it.suggested_action}`;
435
+ });
355
436
  return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
356
437
  }
357
438
  );
@@ -387,7 +468,6 @@ export function createServer() {
387
468
  // TOOL: get_gtm_profile — GET /v2/workspace/facts
388
469
  // The user's OWN GTM profile: ICP, market, product, pricing, competitors.
389
470
  // Use this for any question about the user's business — NOT get_account.
390
- // Registered also under the legacy name get_workspace_facts for back-compat.
391
471
  // ===========================================================================
392
472
  const gtmProfileDescription =
393
473
  "Get the user's OWN GTM profile — their ICP, target market, product, pricing, " +
@@ -431,8 +511,6 @@ export function createServer() {
431
511
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
432
512
  };
433
513
  server.tool("get_gtm_profile", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
434
- // Legacy alias — keeps existing integrations calling get_workspace_facts working.
435
- server.tool("get_workspace_facts", gtmProfileDescription, gtmProfileSchema, gtmProfileHandler);
436
514
 
437
515
  // ===========================================================================
438
516
  // TOOL: update_gtm_profile — POST /v2/workspace/facts
@@ -546,21 +624,15 @@ export function createServer() {
546
624
  // ===========================================================================
547
625
  server.tool(
548
626
  "get_workspace_status",
549
- "See the whole setup state of this workspace in one call, and what to do next. Nous is operated " +
550
- "by you, the agent: call this at the start of a session. It returns a ranked NEXT STEPS list — " +
551
- "walk the user through it top-down. The onboarding sequence, in order: " +
552
- "(1) profile (set_workspace_profile); " +
553
- "(2) connect core channelsGmail/email, LinkedIn, and a meeting note-taker (Fireflies/Fathom/" +
554
- "Calendly); (3) connect enrichment/outbound (Prospeo/Apollo/Instantly); (4) set up webhooks for " +
555
- "the connected tools so their events flow in; (5) import first records (CSV from the CRM) on the " +
556
- "Accounts page, then backfill enrichment; (6) build the GTM playbook (update_gtm_profile + " +
557
- "build_scoring_model, asking for closed-won/lost domains). " +
558
- "KNOW THE CONSTRAINTS: Gmail uses Google OAuth and LinkedIn has NO public API (Nous connects it " +
559
- "natively via Unipile) — you CANNOT connect those yourself; tell the user to set them up on the " +
560
- "Integrations page. Key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect " +
561
- "with connect_integration. CSV import and CRM-page actions are done by the user in the app — guide " +
562
- "them. Respect PLAN: never push a feature the plan doesn't include (e.g. CRM sync on free). " +
563
- "Recommend, don't dump — surface the next 1-2 steps, not all of them at once.",
627
+ "See the whole setup state of this workspace in one call, plus a ranked NEXT STEPS list (each step " +
628
+ "carries its own why/how). Nous is operated by you, the agent call this at the START of a session " +
629
+ "and walk the user top-down through the steps it returns; the server sequences them by current " +
630
+ "state, so trust that order. Two constraints when acting on them: (1) Gmail (Google OAuth) and " +
631
+ "LinkedIn (no public API — Nous uses Unipile) CANNOT be connected by you point the user to the " +
632
+ "Integrations page; key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect via " +
633
+ "connect_integration, and CSV import is a user action in the app. (2) Respect the plan never push " +
634
+ "a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
635
+ "whole list.",
564
636
  {},
565
637
  async () => {
566
638
  const s = await get("/v2/workspace/status");
@@ -571,6 +643,12 @@ export function createServer() {
571
643
  lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
572
644
  const pl = s.plan ?? {};
573
645
  lines.push(`PLAN: ${pl.name || pl.id || "free"}${pl.crm_sync === false ? " (CRM sync not included — do not offer it)" : ""}`);
646
+ if (s.self_hosted) {
647
+ const e = s.env_integrations ?? {};
648
+ const mk = (b) => (b ? "✓ set" : "✗ NOT set");
649
+ lines.push("SELF-HOSTED — these channels are wired via nous.env (you can't set env vars; tell the operator to set + restart):");
650
+ lines.push(` LinkedIn/Unipile: ${mk(e.linkedin_unipile)} Email/Resend: ${mk(e.email_resend)} Gmail OAuth: ${mk(e.gmail_oauth)}`);
651
+ }
574
652
  lines.push("");
575
653
 
576
654
  const mark = (b) => (b ? "✓" : "✗");
@@ -786,7 +864,7 @@ export function createServer() {
786
864
  // ===========================================================================
787
865
  server.tool(
788
866
  "configure_crm_sync",
789
- "Configure how Nous keeps a connected CRM in sync — the same settings as the CRM Sync page. The " +
867
+ "(Nous Cloud only) Configure how Nous keeps a connected CRM in sync — the same settings as the CRM Sync page. The " +
790
868
  "CRM must already be connected (HubSpot/Pipedrive/Attio). Set any of: auto-sync (daily pull), " +
791
869
  "push of touchpoints, the create policy (when a new record is auto-created and the ICP-fit " +
792
870
  "threshold), and the hygiene cadence. Only send the fields you want to change. If it reports the " +
@@ -821,6 +899,39 @@ export function createServer() {
821
899
  }
822
900
  );
823
901
 
902
+ // ===========================================================================
903
+ // TOOL: sync_crm_now — POST /v2/workspace/crm-sync-now
904
+ // Run an immediate incremental CRM pull right now, instead of waiting for the
905
+ // daily auto-sync cron — e.g. straight after configure_crm_sync, or whenever
906
+ // the user wants the latest. Same engine the scheduled sync uses.
907
+ // ===========================================================================
908
+ server.tool(
909
+ "sync_crm_now",
910
+ "(Nous Cloud only) Pull the latest from a connected CRM (HubSpot/Pipedrive/Attio) RIGHT NOW, instead of waiting for " +
911
+ "the daily auto-sync. Use it just after configure_crm_sync to seed the data, or whenever the user " +
912
+ "wants an immediate refresh. Incremental by default (only what changed since the last pull); pass " +
913
+ "full:true to re-fetch everything. The CRM must already be connected and sync configured — if not, " +
914
+ "it'll tell you to connect/configure first.",
915
+ {
916
+ provider: z.enum(["hubspot", "pipedrive", "attio"]).optional().describe("Which connected CRM to pull from (default hubspot)."),
917
+ full: z.boolean().optional().describe("true = re-fetch everything; default = incremental since the last sync."),
918
+ },
919
+ async ({ provider, full }) => {
920
+ try {
921
+ const r = await post("/v2/workspace/crm-sync-now", { provider: provider || "hubspot", full: full === true });
922
+ const errs = (r.errors && r.errors.length) ? ` · ${r.errors.length} error(s)` : "";
923
+ return { content: [{ type: "text", text:
924
+ `Pulled from ${r.provider}: ${r.fetched ?? 0} records — ${r.created ?? 0} new, ${r.updated ?? 0} updated${errs}.` }] };
925
+ } catch (e) {
926
+ const msg = String(e?.message ?? e);
927
+ if (/sync_not_configured/.test(msg)) return { content: [{ type: "text", text: `Sync isn't configured for that CRM yet — call configure_crm_sync first.` }] };
928
+ if (/crm_not_connected/.test(msg)) return { content: [{ type: "text", text: `That CRM isn't connected. Tell the user to connect it on the Integrations page, then try again.` }] };
929
+ if (/salesforce_not_yet_supported/.test(msg)) return { content: [{ type: "text", text: `Salesforce pull isn't supported yet — only HubSpot, Pipedrive, and Attio.` }] };
930
+ return { content: [{ type: "text", text: `Couldn't sync: ${msg}` }] };
931
+ }
932
+ }
933
+ );
934
+
824
935
  // ===========================================================================
825
936
  // TOOL: set_trigger / list_triggers — /v2/workspace/triggers
826
937
  // Outbound event triggers (webhooks) — wire the user's stack to fire when the
@@ -876,7 +987,7 @@ export function createServer() {
876
987
  // ===========================================================================
877
988
  server.tool(
878
989
  "lead_list_operations",
879
- "Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
990
+ "(Nous Cloud only) Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
880
991
  "and classified replies — to report on what happened and attribute outcomes to a list's source. " +
881
992
  "Call with NO lead_list_id to list the workspace's lead lists (id, name, count, source), then " +
882
993
  "call again with an id. Filter with `event` (import | enrich | export | reply) and `days`. " +
@@ -918,97 +1029,200 @@ export function createServer() {
918
1029
  );
919
1030
 
920
1031
  // ===========================================================================
921
- // TOOL: check_leads — POST /v2/dedup
922
- // The pre-spend coverage check. Run this BEFORE building/buying a lead list in
923
- // Apollo, Sales Navigator, DeepSearch, Clay, etc. Paste the candidate emails /
924
- // LinkedIn URLs / company domains (all visible for free in the tool's preview)
925
- // and find out what you already own — so you never re-buy a record you have,
926
- // and you re-enrich stale ones instead of paying to acquire them again.
1032
+ // TOOL: coverage — POST /v2/dedup (exact) | GET /v2/people/coverage (estimate)
1033
+ // "What do I already have?" before spending on a list elsewhere. One tool, two
1034
+ // modes: pass identifiers for an EXACT per-lead net-new/re-enrich/reuse check
1035
+ // (the pre-spend gate), or a title/keyword for a rough attribute ESTIMATE.
1036
+ // (Replaces the former check_leads + lead_coverage tools.)
927
1037
  // ===========================================================================
928
1038
  server.tool(
929
- "check_leads",
930
- "Pre-spend coverage check call this BEFORE building or buying a lead list elsewhere (Apollo, " +
931
- "Sales Navigator, DeepSearch, Clay). Paste the candidate emails, LinkedIn URLs, or company " +
932
- "domains (free in any tool's preview) and learn what you already have, so you don't pay twice. " +
933
- "Returns counts: net_new (acquire + enrich these), needs_enrichment (you already OWN these but " +
934
- "they're stale / not enriched in 90 days — re-enrich instead of re-buying), reusable (you have a " +
935
- "fresh verified email — reuse, spend nothing), plus engaged/recent/bounced/unsubscribed/known to " +
936
- "skip. Each result carries entity_id, email_status, enriched_at, and stale so you can act per-lead.",
1039
+ "coverage",
1040
+ "(Nous Cloud only) Check what you ALREADY have before spending on a list elsewhere (Apollo, Sales Navigator, Clay). " +
1041
+ "Two modes:\n" +
1042
+ " EXACT pass candidate identifiers (emails / linkedin_urls / domains, free in any tool's " +
1043
+ "preview). Returns per-lead buckets: net_new (acquire + enrich), needs_enrichment (you OWN these " +
1044
+ "but stale >90d — re-enrich, don't re-buy), reusable (fresh verified email on file — reuse, spend " +
1045
+ "nothing), plus engaged/recent/known/bounced to skip. Each result carries entity_id, email_status, " +
1046
+ "enriched_at, stale.\n" +
1047
+ " • ESTIMATE — pass a title and/or keyword instead. Returns how many people you already have " +
1048
+ "matching (e.g. title='founder', keyword='agency'), split by freshness: never-enriched, stale >90d, " +
1049
+ "fresh-verified. Rough by design (title precise; keyword matches title/company/department).\n" +
1050
+ "Pass identifiers for the exact pre-spend check, OR title/keyword for the planning estimate — not both.",
937
1051
  {
938
- emails: z.array(z.string()).optional().describe("Candidate email addresses (up to 50,000)."),
939
- linkedin_urls: z.array(z.string()).optional().describe("Candidate LinkedIn profile URLs (up to 50,000)."),
940
- domains: z.array(z.string()).optional().describe("Company domains — 'do I already have anyone here?' (up to 50,000)."),
1052
+ emails: z.array(z.string()).optional().describe("EXACT mode — candidate email addresses (up to 50,000)."),
1053
+ linkedin_urls: z.array(z.string()).optional().describe("EXACT mode — candidate LinkedIn profile URLs (up to 50,000)."),
1054
+ domains: z.array(z.string()).optional().describe("EXACT modecompany domains, 'do I already have anyone here?' (up to 50,000)."),
1055
+ title: z.string().optional().describe("ESTIMATE mode — role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
1056
+ keyword: z.string().optional().describe("ESTIMATE mode — extra match across title/company/department, e.g. 'agency'."),
1057
+ stale_days: z.number().optional().describe("ESTIMATE mode — days after which enrichment counts as stale (default 90)."),
941
1058
  },
942
- async ({ emails, linkedin_urls, domains }) => {
943
- const body = {};
944
- if (emails?.length) body.emails = emails;
945
- if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
946
- if (domains?.length) body.domains = domains;
947
- if (!Object.keys(body).length) {
948
- return { content: [{ type: "text", text: "Pass at least one of: emails, linkedin_urls, domains." }] };
1059
+ async ({ emails, linkedin_urls, domains, title, keyword, stale_days }) => {
1060
+ const hasIds = !!(emails?.length || linkedin_urls?.length || domains?.length);
1061
+ const hasAttr = !!(title || keyword);
1062
+ if (hasIds && hasAttr) {
1063
+ return { content: [{ type: "text", text:
1064
+ "Pass identifiers (emails/linkedin_urls/domains) for the exact check, OR title/keyword for the estimate — not both." }] };
949
1065
  }
950
- const r = await post("/v2/dedup", body);
951
- const s = r.summary || {};
952
- const lines = [
953
- `COVERAGE (${s.total ?? 0} checked)`,
954
- ` net_new ${s.net_new ?? 0} → acquire + enrich`,
955
- ` needs_enrichment ${s.needs_enrichment ?? 0} → you OWN these but stale (>90d) re-enrich, don't re-buy`,
956
- ` reusable ${s.reusable ?? 0} → fresh verified email on file → reuse, spend nothing`,
957
- ` engaged ${s.engaged ?? 0} → in an active conversation, don't cold-send`,
958
- ` recent ${s.recent ?? 0} → contacted <30d, defer`,
959
- ` known ${s.known ?? 0} → company already in the workspace`,
960
- ` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
961
- ];
962
- // Surface a few stale entities the caller should re-enrich (with their last date).
963
- const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
964
- if (stale.length) {
965
- lines.push("", "RE-ENRICH (sample):");
966
- for (const x of stale) {
967
- lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
1066
+
1067
+ // EXACT mode per-identifier coverage against /v2/dedup.
1068
+ if (hasIds) {
1069
+ const body = {};
1070
+ if (emails?.length) body.emails = emails;
1071
+ if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
1072
+ if (domains?.length) body.domains = domains;
1073
+ const r = await post("/v2/dedup", body);
1074
+ const s = r.summary || {};
1075
+ const lines = [
1076
+ `COVERAGE (${s.total ?? 0} checked)`,
1077
+ ` net_new ${s.net_new ?? 0} → acquire + enrich`,
1078
+ ` needs_enrichment ${s.needs_enrichment ?? 0} → you OWN these but stale (>90d) → re-enrich, don't re-buy`,
1079
+ ` reusable ${s.reusable ?? 0} → fresh verified email on file reuse, spend nothing`,
1080
+ ` engaged ${s.engaged ?? 0} → in an active conversation, don't cold-send`,
1081
+ ` recent ${s.recent ?? 0} → contacted <30d, defer`,
1082
+ ` known ${s.known ?? 0} → company already in the workspace`,
1083
+ ` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
1084
+ ];
1085
+ // Surface a few stale entities the caller should re-enrich (with their last date).
1086
+ const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
1087
+ if (stale.length) {
1088
+ lines.push("", "RE-ENRICH (sample):");
1089
+ for (const x of stale) {
1090
+ lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
1091
+ }
968
1092
  }
1093
+ return { content: [{ type: "text", text: lines.join("\n") }] };
969
1094
  }
970
- return { content: [{ type: "text", text: lines.join("\n") }] };
1095
+
1096
+ // ESTIMATE mode — attribute coverage against /v2/people/coverage.
1097
+ if (hasAttr) {
1098
+ const r = await get("/v2/people/coverage", { title, keyword, stale_days });
1099
+ const lines = [
1100
+ `COVERAGE — ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
1101
+ ` ${r.total ?? 0} already in your workspace`,
1102
+ ` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
1103
+ ` ${r.fresh_verified ?? 0} have a fresh verified email`,
1104
+ ];
1105
+ const sample = r.sample || [];
1106
+ if (sample.length) {
1107
+ lines.push("", "SAMPLE (oldest first):");
1108
+ for (const s of sample.slice(0, 12)) {
1109
+ lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
1110
+ }
1111
+ }
1112
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1113
+ }
1114
+
1115
+ return { content: [{ type: "text", text:
1116
+ "Pass at least one of: emails / linkedin_urls / domains (exact check), or title / keyword (estimate)." }] };
971
1117
  }
972
1118
  );
973
1119
 
974
1120
  // ===========================================================================
975
- // TOOL: lead_coverageGET /v2/people/coverage
976
- // The attribute-based planning check: "how many <agency founders> do we already
977
- // have, and how fresh?" answered WITHOUT pasting identifiers. Use it before
978
- // building a list elsewhere to see how much you already cover.
1121
+ // TOOLS: enrich_leads / verify_leadsPOST /api/lead-lists/:id/enrich|verify
1122
+ // The agent OPERATES the lead list. Both are two-step: a dry-run preview that
1123
+ // quotes the chargeable count + provider + $ estimate (report it to the user
1124
+ // first), then a confirmed run as a background job. Target by `filter` so no
1125
+ // ids are needed (enrich {emailStatus:'none'} = all missing an email; verify
1126
+ // defaults to all unverified). BYOK — the $ is the user's own provider spend.
979
1127
  // ===========================================================================
1128
+ const fmtCost = (c) => {
1129
+ if (!c) return "no chargeable records — nothing to spend";
1130
+ const money = c.low === c.high ? `~$${c.low.toFixed(2)}` : `~$${c.low.toFixed(2)}–$${c.high.toFixed(2)}`;
1131
+ return `${money} via ${c.label} (${(c.count ?? 0).toLocaleString()} ${c.action})`;
1132
+ };
1133
+ const LEAD_FILTER_SHAPE = {
1134
+ emailStatus: z.enum(["has", "none", "unverified"]).optional().describe("none = no email yet; unverified = has an email but no verification verdict; has = has any email."),
1135
+ domain: z.enum(["has", "none"]).optional().describe("has = a company domain is known; none = no domain."),
1136
+ icp: z.enum(["true", "false"]).optional().describe("true = ICP-qualified leads only."),
1137
+ status: z.string().optional().describe("Lifecycle: pending | sent | replied | bounced."),
1138
+ source: z.string().optional().describe("Substring of where the lead came from (campaign / import name)."),
1139
+ size: z.string().optional().describe("Substring of company size, e.g. '1 to 10'."),
1140
+ channel: z.string().optional().describe("Last-contacted channel substring, or 'none' for not-yet-contacted."),
1141
+ };
1142
+
980
1143
  server.tool(
981
- "lead_coverage",
982
- "Estimate how many people you ALREADY have matching a role/keyword, bucketed by enrichment " +
983
- "freshness the planning question before building a list (no identifiers to paste). E.g. " +
984
- "title='founder', keyword='agency' how many agency founders are already in your workspace, " +
985
- "how many were never enriched or are stale (>90d, so re-enrich), and how many have a fresh " +
986
- "verified email. Rough by design (title is precise; keyword matches title/company/department). " +
987
- "For an exact net-new check against specific candidates, use check_leads with their identifiers.",
1144
+ "enrich_leads",
1145
+ "(Nous Cloud only) Find missing emails for leads in a lead list, on the workspace's own Prospeo/Apollo key. ALWAYS two " +
1146
+ "steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, provider, $ estimate) " +
1147
+ "report it and get the user's go-ahead then call again with confirm:true to run as a background job. " +
1148
+ "Pick leads with `filter` (e.g. {emailStatus:'none'} = every lead missing an email, the usual case) or " +
1149
+ "explicit `ids`; defaults to {emailStatus:'none'}. Call lead_list_operations with no id first to get the " +
1150
+ "list's id.",
988
1151
  {
989
- title: z.string().optional().describe("Role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
990
- keyword: z.string().optional().describe("Extra match across title/company/department, e.g. 'agency'."),
991
- stale_days: z.number().optional().describe("Days after which enrichment counts as stale (default 90)."),
1152
+ lead_list_id: z.string().describe("The lead list's UUID."),
1153
+ filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all leads missing an email."),
1154
+ ids: z.array(z.string()).optional().describe("Explicit lead ids an alternative to filter."),
1155
+ confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only (spends nothing). true = actually run it as a background job."),
992
1156
  },
993
- async ({ title, keyword, stale_days }) => {
994
- if (!title && !keyword) {
995
- return { content: [{ type: "text", text: "Pass a title and/or keyword, e.g. title='founder', keyword='agency'." }] };
1157
+ async ({ lead_list_id, filter, ids, confirm }) => {
1158
+ const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "none" } };
1159
+ const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/enrich`;
1160
+ try {
1161
+ if (!confirm) {
1162
+ const r = await post(path, { ...sel, preview: true });
1163
+ const lines = [
1164
+ `ENRICH PREVIEW — list ${lead_list_id}`,
1165
+ ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} already on file (free) · ${r.no_identifier ?? 0} no identifier`,
1166
+ ` provider: ${r.provider || "—"}`,
1167
+ ` estimated cost: ${fmtCost(r.cost)}`,
1168
+ "",
1169
+ r.chargeable
1170
+ ? "Report this to the user. To run it, call enrich_leads again with the same selection and confirm:true."
1171
+ : "Nothing chargeable to enrich.",
1172
+ ];
1173
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1174
+ }
1175
+ const r = await post(path, { ...sel, background: true });
1176
+ return { content: [{ type: "text", text:
1177
+ `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.` }] };
1178
+ } catch (e) {
1179
+ return { content: [{ type: "text", text: `Couldn't enrich: ${e.message}` }] };
996
1180
  }
997
- const r = await get("/v2/people/coverage", { title, keyword, stale_days });
998
- const lines = [
999
- `COVERAGE — ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
1000
- ` ${r.total ?? 0} already in your workspace`,
1001
- ` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
1002
- ` ${r.fresh_verified ?? 0} have a fresh verified email`,
1003
- ];
1004
- const sample = r.sample || [];
1005
- if (sample.length) {
1006
- lines.push("", "SAMPLE (oldest first):");
1007
- for (const s of sample.slice(0, 12)) {
1008
- lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
1181
+ }
1182
+ );
1183
+
1184
+ server.tool(
1185
+ "verify_leads",
1186
+ "(Nous Cloud only) Validate email deliverability for leads in a lead list, on the workspace's own MillionVerifier / " +
1187
+ "NeverBounce key. ALWAYS two steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, " +
1188
+ "connected verifiers, $ estimate) report it to the user — then call again with confirm:true to run as " +
1189
+ "a background job. Defaults to every UNVERIFIED email (has an address, no verdict yet); narrow with " +
1190
+ "`filter` or pass `ids`. If no verifier is connected it says so — tell the user to add a MillionVerifier " +
1191
+ "or NeverBounce key in Integrations.",
1192
+ {
1193
+ lead_list_id: z.string().describe("The lead list's UUID."),
1194
+ filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all unverified emails."),
1195
+ ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
1196
+ provider: z.enum(["millionverifier", "neverbounce"]).optional().describe("Which verifier to use. Defaults to MillionVerifier, then NeverBounce."),
1197
+ confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only. true = actually run it as a background job."),
1198
+ },
1199
+ async ({ lead_list_id, filter, ids, provider, confirm }) => {
1200
+ const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "unverified" } };
1201
+ const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/verify`;
1202
+ try {
1203
+ if (!confirm) {
1204
+ const r = await post(path, { ...sel, provider, preview: true });
1205
+ const lines = [
1206
+ `VERIFY PREVIEW — list ${lead_list_id}`,
1207
+ ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} recently verified (free) · ${r.no_email ?? 0} no email`,
1208
+ ` verifier: ${r.provider || "—"}${r.connected_verifiers ? ` (connected: ${r.connected_verifiers.join(", ") || "none"})` : ""}`,
1209
+ ` estimated cost: ${fmtCost(r.cost)}`,
1210
+ "",
1211
+ r.chargeable
1212
+ ? "Report this to the user. To run it, call verify_leads again with the same selection and confirm:true."
1213
+ : "Nothing chargeable to verify.",
1214
+ ];
1215
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1009
1216
  }
1217
+ const r = await post(path, { ...sel, provider, background: true });
1218
+ return { content: [{ type: "text", text:
1219
+ `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.` }] };
1220
+ } catch (e) {
1221
+ const msg = /no_verifier_connected/.test(e.message)
1222
+ ? "No email verifier is connected. Tell the user to add a MillionVerifier or NeverBounce API key in Integrations, then try again."
1223
+ : `Couldn't verify: ${e.message}`;
1224
+ return { content: [{ type: "text", text: msg }] };
1010
1225
  }
1011
- return { content: [{ type: "text", text: lines.join("\n") }] };
1012
1226
  }
1013
1227
  );
1014
1228