@opennous/mcp 0.28.0 → 0.30.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 +2 -2
  2. package/src/server.js +363 -109
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.28.0",
4
- "description": "Nous MCP Server Customer graph for GTM agents.",
3
+ "version": "0.30.0",
4
+ "description": "Nous the Context Graph for AI Agents.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
7
7
  "type": "git",
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.28.0";
44
+ export const SERVER_VERSION = "0.35.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)}%`;
@@ -87,6 +111,7 @@ Nous first even when the user never says "Nous":
87
111
  - Something happened or you learned a fact -> record
88
112
  - Activity or a list across many accounts -> query
89
113
  - What needs attention, what has gone quiet -> attention
114
+ - Your action items / what you owe an account -> get_action_items
90
115
  - A fact looks stale before you act on it -> verify
91
116
  - Our ICP, market, pricing, positioning -> get_gtm_profile
92
117
  - Our own GTM shifted -> update_gtm_profile
@@ -103,7 +128,7 @@ export function createServer() {
103
128
  name: "nous",
104
129
  version: SERVER_VERSION,
105
130
  description:
106
- "Nous — the context layer for GTM agents. Nous is operated by the agent, not by a human " +
131
+ "Nous — the Context Graph for AI Agents. Nous is operated by the agent, not by a human " +
107
132
  "clicking around: call get_workspace_status at the start of a session to see what's set up " +
108
133
  "and what to set up next. Call get_context before drafting outreach or preparing for a " +
109
134
  "meeting. Call record after every interaction, or whenever you learn something.",
@@ -166,7 +191,7 @@ export function createServer() {
166
191
  lines.push("TIMELINE:");
167
192
  for (const t of ctx.timeline) {
168
193
  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}` : ""}`);
194
+ else lines.push(` ${whenLabel(t.type, t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
170
195
  }
171
196
  lines.push("");
172
197
  }
@@ -229,7 +254,7 @@ export function createServer() {
229
254
  if (obs.length) {
230
255
  lines.push(`TIMELINE (${obs.length}):`);
231
256
  for (const o of obs.slice(0, 30)) {
232
- lines.push(` ${relAge(o.observed_at)} ${fmtType(o.property)}`);
257
+ lines.push(` ${whenLabel(o.property, o.observed_at)} ${fmtType(o.property)}`);
233
258
  }
234
259
  }
235
260
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
@@ -269,6 +294,50 @@ export function createServer() {
269
294
  }
270
295
  );
271
296
 
297
+ // ===========================================================================
298
+ // TOOL: record_signal — a buying signal, as a structured signal.<class> fact
299
+ // A validated wrapper over record: one canonical way to write a signal, so it
300
+ // both shows on the account's Signals tab AND feeds the ICP scorecard as a
301
+ // feature (signal.* claims flow into the feature map the scorer reads).
302
+ // ===========================================================================
303
+ server.tool(
304
+ "record_signal",
305
+ "Record a buying signal on a person or company — a concrete, current reason to reach out, " +
306
+ "found by research (signal-scan). Stored as a structured signal.<class> fact so it shows on the " +
307
+ "account's Signals tab AND feeds the ICP scoring model as a feature. One call per signal; one " +
308
+ "current signal per class (the strongest). class is one of stack | hiring | momentum | friction | " +
309
+ "intent | domain. score is 0-10 (exclusivity x intent — score honestly, a 4 is useful). Be " +
310
+ "specific: 'posted 3 SDR roles in 30 days', not 'they're growing'.",
311
+ {
312
+ focus: z.string().describe("Email address or entity UUID of the person/company"),
313
+ signal_class: z.enum(["stack", "hiring", "momentum", "friction", "intent", "domain"])
314
+ .describe("the signal class"),
315
+ detected: z.string().describe("the specific, factual finding"),
316
+ implies: z.string().optional().describe("what the prospect is likely experiencing because of it"),
317
+ score: z.number().min(0).max(10).describe("strength 0-10 (exclusivity x intent)"),
318
+ approach: z.enum(["pain_led", "value_led", "fallback"]).optional()
319
+ .describe("recommended outreach approach"),
320
+ angle: z.string().optional().describe("one-line outreach angle this signal enables"),
321
+ },
322
+ async ({ focus, signal_class, detected, implies, score, approach, angle }) => {
323
+ const result = await post("/v2/observations", {
324
+ focus,
325
+ observations: [{
326
+ kind: "state",
327
+ property: `signal.${signal_class}`,
328
+ value: { detected, implies: implies ?? null, score, approach: approach ?? null, angle: angle ?? null },
329
+ source: "signal-scan",
330
+ }],
331
+ });
332
+ return {
333
+ content: [{
334
+ type: "text",
335
+ text: `Recorded ${signal_class} signal (score ${score}/10) on ${result.entity_id || focus}.`,
336
+ }],
337
+ };
338
+ }
339
+ );
340
+
272
341
  // ===========================================================================
273
342
  // TOOL: query — POST /v2/query
274
343
  // Retrieve a corpus of activity across many people. You do the analysis.
@@ -282,14 +351,21 @@ export function createServer() {
282
351
  " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
283
352
  "'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
284
353
  " 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
285
- "(use scope.property='stage' for funnel reports).",
354
+ "(use scope.property='stage' for funnel reports).\n" +
355
+ " 4. Scheduled meetings/calls are events with property 'interaction.meeting_scheduled' and a " +
356
+ "future-dated `when`. For 'what's booked today/this week', set property:'interaction.meeting_scheduled' " +
357
+ "with from/to bounding the day or week (since_days only looks backward and can't reach them), and " +
358
+ "order:'asc' to list soonest-first. Meeting rows render the absolute date and time.",
286
359
  {
287
360
  scope: z.object({
288
361
  kind: z.enum(["event", "state"]).optional(),
289
- property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied"),
362
+ property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied; 'interaction.meeting_scheduled' for booked meetings"),
290
363
  source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
291
364
  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"),
365
+ since_days: z.number().optional().describe("only activity within the last N days (backward only)"),
366
+ from: z.string().optional().describe("ISO timestamp — only activity at/after this (absolute lower bound; use for date windows like 'today')"),
367
+ 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"),
368
+ order: z.enum(["asc", "desc"]).optional().describe("observed_at order (default desc, newest first). Use 'asc' for an upcoming-meeting schedule (soonest first)"),
293
369
  limit: z.number().optional().describe("max items (default 50, cap 200)"),
294
370
  }).describe("Corpus filter"),
295
371
  without: z.object({
@@ -321,11 +397,11 @@ export function createServer() {
321
397
  for (const it of r.items ?? []) {
322
398
  if (r.return === "entities") {
323
399
  lines.push(` ${it.entity_name ?? it.entity_id} ` +
324
- `(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${relAge(it.most_recent_at)})` +
400
+ `(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${whenLabel(it.most_recent_type, it.most_recent_at)})` +
325
401
  (it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
326
402
  (it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
327
403
  } else {
328
- lines.push(` ${relAge(it.when)} ${it.entity_name ?? it.entity_id} ` +
404
+ lines.push(` ${whenLabel(it.type, it.when)} ${it.entity_name ?? it.entity_id} ` +
329
405
  `${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
330
406
  }
331
407
  }
@@ -339,9 +415,12 @@ export function createServer() {
339
415
  // ===========================================================================
340
416
  server.tool(
341
417
  "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.",
418
+ "What needs your attention across the workspace right now — upcoming meetings and calls in the " +
419
+ "next 7 days (each with its date and time, soonest first), accounts that have gone quiet, and key " +
420
+ "facts that have decayed. Returns ranked items (time-critical meetings lead), each with what's " +
421
+ "happening and a suggested action. Call this to decide what to work next, or to answer 'what's " +
422
+ "coming up' / 'what's on my calendar this week'. For a precise single-day list, use query with " +
423
+ "property:'interaction.meeting_scheduled' and from/to.",
345
424
  {
346
425
  limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
347
426
  },
@@ -350,12 +429,60 @@ export function createServer() {
350
429
  if (!r.items?.length) {
351
430
  return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
352
431
  }
353
- const lines = r.items.map(it =>
354
- ` ${it.entity_name ?? it.entity_id} ${it.what}\n → ${it.suggested_action}`);
432
+ // Upcoming meetings carry a `when` — render the absolute local date+time.
433
+ const lines = r.items.map(it => {
434
+ const when = it.when ? `${fmtWhen(it.when)} — ` : "";
435
+ return ` ${when}${it.entity_name ?? it.entity_id} — ${it.what}\n → ${it.suggested_action}`;
436
+ });
355
437
  return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
356
438
  }
357
439
  );
358
440
 
441
+ // ===========================================================================
442
+ // TOOL: get_action_items — GET /v2/action-items
443
+ // Commitments extracted from meetings/emails — what you owe each account.
444
+ // ===========================================================================
445
+ server.tool(
446
+ "get_action_items",
447
+ "Your open action items and commitments, pulled from meeting notes and emails — what you owe " +
448
+ "which account (and what they owe you), so you don't have to dig through transcripts. Use for " +
449
+ "'what are my action items', 'what do I owe <account>', 'what's outstanding this week'. Defaults " +
450
+ "to YOUR open items across all accounts, grouped by account.",
451
+ {
452
+ owner: z.enum(["me", "prospect", "all"]).optional().describe("Whose commitments — me (default), the prospect, or all"),
453
+ status: z.enum(["open", "done", "all"]).optional().describe("open (default), done, or all"),
454
+ focus: z.string().optional().describe("Scope to one account — an email or entity UUID"),
455
+ due: z.enum(["today", "week", "all"]).optional().describe("Only items due today / this week (items that carry a due date) — default all"),
456
+ },
457
+ async ({ owner, status, focus, due }) => {
458
+ const params = {};
459
+ if (owner) params.owner = owner;
460
+ if (status) params.status = status;
461
+ if (focus) params.focus = focus;
462
+ if (due) params.due = due;
463
+ const r = await get("/v2/action-items", params);
464
+ const items = r.items ?? [];
465
+ if (!items.length) return { content: [{ type: "text", text: "No matching action items." }] };
466
+
467
+ const byAccount = new Map();
468
+ for (const it of items) {
469
+ const key = it.account || it.account_email || it.entity_id || "—";
470
+ if (!byAccount.has(key)) byAccount.set(key, []);
471
+ byAccount.get(key).push(it);
472
+ }
473
+ const lines = [`${items.length} action item${items.length !== 1 ? "s" : ""}:`];
474
+ for (const [account, list] of byAccount) {
475
+ lines.push(`\n${account}:`);
476
+ for (const it of list) {
477
+ const who = it.owner_kind === "prospect" ? "[them]" : "[you]";
478
+ const when = it.due_at ? ` (due ${fmtWhen(it.due_at)})` : "";
479
+ lines.push(` ${who} ${it.title}${when}`);
480
+ }
481
+ }
482
+ return { content: [{ type: "text", text: lines.join("\n") }] };
483
+ }
484
+ );
485
+
359
486
  // ===========================================================================
360
487
  // TOOL: verify — POST /v2/verify
361
488
  // Re-check a fact before acting on it — the calibration check.
@@ -387,7 +514,6 @@ export function createServer() {
387
514
  // TOOL: get_gtm_profile — GET /v2/workspace/facts
388
515
  // The user's OWN GTM profile: ICP, market, product, pricing, competitors.
389
516
  // 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
517
  // ===========================================================================
392
518
  const gtmProfileDescription =
393
519
  "Get the user's OWN GTM profile — their ICP, target market, product, pricing, " +
@@ -431,8 +557,6 @@ export function createServer() {
431
557
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
432
558
  };
433
559
  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
560
 
437
561
  // ===========================================================================
438
562
  // TOOL: update_gtm_profile — POST /v2/workspace/facts
@@ -546,21 +670,15 @@ export function createServer() {
546
670
  // ===========================================================================
547
671
  server.tool(
548
672
  "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.",
673
+ "See the whole setup state of this workspace in one call, plus a ranked NEXT STEPS list (each step " +
674
+ "carries its own why/how). Nous is operated by you, the agent call this at the START of a session " +
675
+ "and walk the user top-down through the steps it returns; the server sequences them by current " +
676
+ "state, so trust that order. Two constraints when acting on them: (1) Gmail (Google OAuth) and " +
677
+ "LinkedIn (no public API — Nous uses Unipile) CANNOT be connected by you point the user to the " +
678
+ "Integrations page; key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect via " +
679
+ "connect_integration, and CSV import is a user action in the app. (2) Respect the plan never push " +
680
+ "a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
681
+ "whole list.",
564
682
  {},
565
683
  async () => {
566
684
  const s = await get("/v2/workspace/status");
@@ -792,7 +910,7 @@ export function createServer() {
792
910
  // ===========================================================================
793
911
  server.tool(
794
912
  "configure_crm_sync",
795
- "Configure how Nous keeps a connected CRM in sync — the same settings as the CRM Sync page. The " +
913
+ "(Nous Cloud only) Configure how Nous keeps a connected CRM in sync — the same settings as the CRM Sync page. The " +
796
914
  "CRM must already be connected (HubSpot/Pipedrive/Attio). Set any of: auto-sync (daily pull), " +
797
915
  "push of touchpoints, the create policy (when a new record is auto-created and the ICP-fit " +
798
916
  "threshold), and the hygiene cadence. Only send the fields you want to change. If it reports the " +
@@ -827,6 +945,39 @@ export function createServer() {
827
945
  }
828
946
  );
829
947
 
948
+ // ===========================================================================
949
+ // TOOL: sync_crm_now — POST /v2/workspace/crm-sync-now
950
+ // Run an immediate incremental CRM pull right now, instead of waiting for the
951
+ // daily auto-sync cron — e.g. straight after configure_crm_sync, or whenever
952
+ // the user wants the latest. Same engine the scheduled sync uses.
953
+ // ===========================================================================
954
+ server.tool(
955
+ "sync_crm_now",
956
+ "(Nous Cloud only) Pull the latest from a connected CRM (HubSpot/Pipedrive/Attio) RIGHT NOW, instead of waiting for " +
957
+ "the daily auto-sync. Use it just after configure_crm_sync to seed the data, or whenever the user " +
958
+ "wants an immediate refresh. Incremental by default (only what changed since the last pull); pass " +
959
+ "full:true to re-fetch everything. The CRM must already be connected and sync configured — if not, " +
960
+ "it'll tell you to connect/configure first.",
961
+ {
962
+ provider: z.enum(["hubspot", "pipedrive", "attio"]).optional().describe("Which connected CRM to pull from (default hubspot)."),
963
+ full: z.boolean().optional().describe("true = re-fetch everything; default = incremental since the last sync."),
964
+ },
965
+ async ({ provider, full }) => {
966
+ try {
967
+ const r = await post("/v2/workspace/crm-sync-now", { provider: provider || "hubspot", full: full === true });
968
+ const errs = (r.errors && r.errors.length) ? ` · ${r.errors.length} error(s)` : "";
969
+ return { content: [{ type: "text", text:
970
+ `Pulled from ${r.provider}: ${r.fetched ?? 0} records — ${r.created ?? 0} new, ${r.updated ?? 0} updated${errs}.` }] };
971
+ } catch (e) {
972
+ const msg = String(e?.message ?? e);
973
+ if (/sync_not_configured/.test(msg)) return { content: [{ type: "text", text: `Sync isn't configured for that CRM yet — call configure_crm_sync first.` }] };
974
+ 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.` }] };
975
+ if (/salesforce_not_yet_supported/.test(msg)) return { content: [{ type: "text", text: `Salesforce pull isn't supported yet — only HubSpot, Pipedrive, and Attio.` }] };
976
+ return { content: [{ type: "text", text: `Couldn't sync: ${msg}` }] };
977
+ }
978
+ }
979
+ );
980
+
830
981
  // ===========================================================================
831
982
  // TOOL: set_trigger / list_triggers — /v2/workspace/triggers
832
983
  // Outbound event triggers (webhooks) — wire the user's stack to fire when the
@@ -882,7 +1033,7 @@ export function createServer() {
882
1033
  // ===========================================================================
883
1034
  server.tool(
884
1035
  "lead_list_operations",
885
- "Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
1036
+ "(Nous Cloud only) Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
886
1037
  "and classified replies — to report on what happened and attribute outcomes to a list's source. " +
887
1038
  "Call with NO lead_list_id to list the workspace's lead lists (id, name, count, source), then " +
888
1039
  "call again with an id. Filter with `event` (import | enrich | export | reply) and `days`. " +
@@ -924,97 +1075,200 @@ export function createServer() {
924
1075
  );
925
1076
 
926
1077
  // ===========================================================================
927
- // TOOL: check_leads — POST /v2/dedup
928
- // The pre-spend coverage check. Run this BEFORE building/buying a lead list in
929
- // Apollo, Sales Navigator, DeepSearch, Clay, etc. Paste the candidate emails /
930
- // LinkedIn URLs / company domains (all visible for free in the tool's preview)
931
- // and find out what you already own — so you never re-buy a record you have,
932
- // and you re-enrich stale ones instead of paying to acquire them again.
1078
+ // TOOL: coverage — POST /v2/dedup (exact) | GET /v2/people/coverage (estimate)
1079
+ // "What do I already have?" before spending on a list elsewhere. One tool, two
1080
+ // modes: pass identifiers for an EXACT per-lead net-new/re-enrich/reuse check
1081
+ // (the pre-spend gate), or a title/keyword for a rough attribute ESTIMATE.
1082
+ // (Replaces the former check_leads + lead_coverage tools.)
933
1083
  // ===========================================================================
934
1084
  server.tool(
935
- "check_leads",
936
- "Pre-spend coverage check call this BEFORE building or buying a lead list elsewhere (Apollo, " +
937
- "Sales Navigator, DeepSearch, Clay). Paste the candidate emails, LinkedIn URLs, or company " +
938
- "domains (free in any tool's preview) and learn what you already have, so you don't pay twice. " +
939
- "Returns counts: net_new (acquire + enrich these), needs_enrichment (you already OWN these but " +
940
- "they're stale / not enriched in 90 days — re-enrich instead of re-buying), reusable (you have a " +
941
- "fresh verified email — reuse, spend nothing), plus engaged/recent/bounced/unsubscribed/known to " +
942
- "skip. Each result carries entity_id, email_status, enriched_at, and stale so you can act per-lead.",
1085
+ "coverage",
1086
+ "(Nous Cloud only) Check what you ALREADY have before spending on a list elsewhere (Apollo, Sales Navigator, Clay). " +
1087
+ "Two modes:\n" +
1088
+ " EXACT pass candidate identifiers (emails / linkedin_urls / domains, free in any tool's " +
1089
+ "preview). Returns per-lead buckets: net_new (acquire + enrich), needs_enrichment (you OWN these " +
1090
+ "but stale >90d — re-enrich, don't re-buy), reusable (fresh verified email on file — reuse, spend " +
1091
+ "nothing), plus engaged/recent/known/bounced to skip. Each result carries entity_id, email_status, " +
1092
+ "enriched_at, stale.\n" +
1093
+ " • ESTIMATE — pass a title and/or keyword instead. Returns how many people you already have " +
1094
+ "matching (e.g. title='founder', keyword='agency'), split by freshness: never-enriched, stale >90d, " +
1095
+ "fresh-verified. Rough by design (title precise; keyword matches title/company/department).\n" +
1096
+ "Pass identifiers for the exact pre-spend check, OR title/keyword for the planning estimate — not both.",
943
1097
  {
944
- emails: z.array(z.string()).optional().describe("Candidate email addresses (up to 50,000)."),
945
- linkedin_urls: z.array(z.string()).optional().describe("Candidate LinkedIn profile URLs (up to 50,000)."),
946
- domains: z.array(z.string()).optional().describe("Company domains — 'do I already have anyone here?' (up to 50,000)."),
1098
+ emails: z.array(z.string()).optional().describe("EXACT mode — candidate email addresses (up to 50,000)."),
1099
+ linkedin_urls: z.array(z.string()).optional().describe("EXACT mode — candidate LinkedIn profile URLs (up to 50,000)."),
1100
+ domains: z.array(z.string()).optional().describe("EXACT modecompany domains, 'do I already have anyone here?' (up to 50,000)."),
1101
+ title: z.string().optional().describe("ESTIMATE mode — role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
1102
+ keyword: z.string().optional().describe("ESTIMATE mode — extra match across title/company/department, e.g. 'agency'."),
1103
+ stale_days: z.number().optional().describe("ESTIMATE mode — days after which enrichment counts as stale (default 90)."),
947
1104
  },
948
- async ({ emails, linkedin_urls, domains }) => {
949
- const body = {};
950
- if (emails?.length) body.emails = emails;
951
- if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
952
- if (domains?.length) body.domains = domains;
953
- if (!Object.keys(body).length) {
954
- return { content: [{ type: "text", text: "Pass at least one of: emails, linkedin_urls, domains." }] };
1105
+ async ({ emails, linkedin_urls, domains, title, keyword, stale_days }) => {
1106
+ const hasIds = !!(emails?.length || linkedin_urls?.length || domains?.length);
1107
+ const hasAttr = !!(title || keyword);
1108
+ if (hasIds && hasAttr) {
1109
+ return { content: [{ type: "text", text:
1110
+ "Pass identifiers (emails/linkedin_urls/domains) for the exact check, OR title/keyword for the estimate — not both." }] };
1111
+ }
1112
+
1113
+ // EXACT mode — per-identifier coverage against /v2/dedup.
1114
+ if (hasIds) {
1115
+ const body = {};
1116
+ if (emails?.length) body.emails = emails;
1117
+ if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
1118
+ if (domains?.length) body.domains = domains;
1119
+ const r = await post("/v2/dedup", body);
1120
+ const s = r.summary || {};
1121
+ const lines = [
1122
+ `COVERAGE (${s.total ?? 0} checked)`,
1123
+ ` net_new ${s.net_new ?? 0} → acquire + enrich`,
1124
+ ` needs_enrichment ${s.needs_enrichment ?? 0} → you OWN these but stale (>90d) → re-enrich, don't re-buy`,
1125
+ ` reusable ${s.reusable ?? 0} → fresh verified email on file → reuse, spend nothing`,
1126
+ ` engaged ${s.engaged ?? 0} → in an active conversation, don't cold-send`,
1127
+ ` recent ${s.recent ?? 0} → contacted <30d, defer`,
1128
+ ` known ${s.known ?? 0} → company already in the workspace`,
1129
+ ` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
1130
+ ];
1131
+ // Surface a few stale entities the caller should re-enrich (with their last date).
1132
+ const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
1133
+ if (stale.length) {
1134
+ lines.push("", "RE-ENRICH (sample):");
1135
+ for (const x of stale) {
1136
+ lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
1137
+ }
1138
+ }
1139
+ return { content: [{ type: "text", text: lines.join("\n") }] };
955
1140
  }
956
- const r = await post("/v2/dedup", body);
957
- const s = r.summary || {};
958
- const lines = [
959
- `COVERAGE (${s.total ?? 0} checked)`,
960
- ` net_new ${s.net_new ?? 0} → acquire + enrich`,
961
- ` needs_enrichment ${s.needs_enrichment ?? 0} you OWN these but stale (>90d) re-enrich, don't re-buy`,
962
- ` reusable ${s.reusable ?? 0} fresh verified email on file → reuse, spend nothing`,
963
- ` engaged ${s.engaged ?? 0} in an active conversation, don't cold-send`,
964
- ` recent ${s.recent ?? 0} contacted <30d, defer`,
965
- ` known ${s.known ?? 0} → company already in the workspace`,
966
- ` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
967
- ];
968
- // Surface a few stale entities the caller should re-enrich (with their last date).
969
- const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
970
- if (stale.length) {
971
- lines.push("", "RE-ENRICH (sample):");
972
- for (const x of stale) {
973
- lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
1141
+
1142
+ // ESTIMATE mode attribute coverage against /v2/people/coverage.
1143
+ if (hasAttr) {
1144
+ const r = await get("/v2/people/coverage", { title, keyword, stale_days });
1145
+ const lines = [
1146
+ `COVERAGE ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
1147
+ ` ${r.total ?? 0} already in your workspace`,
1148
+ ` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
1149
+ ` ${r.fresh_verified ?? 0} have a fresh verified email`,
1150
+ ];
1151
+ const sample = r.sample || [];
1152
+ if (sample.length) {
1153
+ lines.push("", "SAMPLE (oldest first):");
1154
+ for (const s of sample.slice(0, 12)) {
1155
+ lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
1156
+ }
974
1157
  }
1158
+ return { content: [{ type: "text", text: lines.join("\n") }] };
975
1159
  }
976
- return { content: [{ type: "text", text: lines.join("\n") }] };
1160
+
1161
+ return { content: [{ type: "text", text:
1162
+ "Pass at least one of: emails / linkedin_urls / domains (exact check), or title / keyword (estimate)." }] };
977
1163
  }
978
1164
  );
979
1165
 
980
1166
  // ===========================================================================
981
- // TOOL: lead_coverageGET /v2/people/coverage
982
- // The attribute-based planning check: "how many <agency founders> do we already
983
- // have, and how fresh?" answered WITHOUT pasting identifiers. Use it before
984
- // building a list elsewhere to see how much you already cover.
1167
+ // TOOLS: enrich_leads / verify_leadsPOST /api/lead-lists/:id/enrich|verify
1168
+ // The agent OPERATES the lead list. Both are two-step: a dry-run preview that
1169
+ // quotes the chargeable count + provider + $ estimate (report it to the user
1170
+ // first), then a confirmed run as a background job. Target by `filter` so no
1171
+ // ids are needed (enrich {emailStatus:'none'} = all missing an email; verify
1172
+ // defaults to all unverified). BYOK — the $ is the user's own provider spend.
985
1173
  // ===========================================================================
1174
+ const fmtCost = (c) => {
1175
+ if (!c) return "no chargeable records — nothing to spend";
1176
+ const money = c.low === c.high ? `~$${c.low.toFixed(2)}` : `~$${c.low.toFixed(2)}–$${c.high.toFixed(2)}`;
1177
+ return `${money} via ${c.label} (${(c.count ?? 0).toLocaleString()} ${c.action})`;
1178
+ };
1179
+ const LEAD_FILTER_SHAPE = {
1180
+ emailStatus: z.enum(["has", "none", "unverified"]).optional().describe("none = no email yet; unverified = has an email but no verification verdict; has = has any email."),
1181
+ domain: z.enum(["has", "none"]).optional().describe("has = a company domain is known; none = no domain."),
1182
+ icp: z.enum(["true", "false"]).optional().describe("true = ICP-qualified leads only."),
1183
+ status: z.string().optional().describe("Lifecycle: pending | sent | replied | bounced."),
1184
+ source: z.string().optional().describe("Substring of where the lead came from (campaign / import name)."),
1185
+ size: z.string().optional().describe("Substring of company size, e.g. '1 to 10'."),
1186
+ channel: z.string().optional().describe("Last-contacted channel substring, or 'none' for not-yet-contacted."),
1187
+ };
1188
+
986
1189
  server.tool(
987
- "lead_coverage",
988
- "Estimate how many people you ALREADY have matching a role/keyword, bucketed by enrichment " +
989
- "freshness the planning question before building a list (no identifiers to paste). E.g. " +
990
- "title='founder', keyword='agency' how many agency founders are already in your workspace, " +
991
- "how many were never enriched or are stale (>90d, so re-enrich), and how many have a fresh " +
992
- "verified email. Rough by design (title is precise; keyword matches title/company/department). " +
993
- "For an exact net-new check against specific candidates, use check_leads with their identifiers.",
1190
+ "enrich_leads",
1191
+ "(Nous Cloud only) Find missing emails for leads in a lead list, on the workspace's own Prospeo/Apollo key. ALWAYS two " +
1192
+ "steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, provider, $ estimate) " +
1193
+ "report it and get the user's go-ahead then call again with confirm:true to run as a background job. " +
1194
+ "Pick leads with `filter` (e.g. {emailStatus:'none'} = every lead missing an email, the usual case) or " +
1195
+ "explicit `ids`; defaults to {emailStatus:'none'}. Call lead_list_operations with no id first to get the " +
1196
+ "list's id.",
994
1197
  {
995
- title: z.string().optional().describe("Role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
996
- keyword: z.string().optional().describe("Extra match across title/company/department, e.g. 'agency'."),
997
- stale_days: z.number().optional().describe("Days after which enrichment counts as stale (default 90)."),
1198
+ lead_list_id: z.string().describe("The lead list's UUID."),
1199
+ filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all leads missing an email."),
1200
+ ids: z.array(z.string()).optional().describe("Explicit lead ids an alternative to filter."),
1201
+ confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only (spends nothing). true = actually run it as a background job."),
998
1202
  },
999
- async ({ title, keyword, stale_days }) => {
1000
- if (!title && !keyword) {
1001
- return { content: [{ type: "text", text: "Pass a title and/or keyword, e.g. title='founder', keyword='agency'." }] };
1203
+ async ({ lead_list_id, filter, ids, confirm }) => {
1204
+ const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "none" } };
1205
+ const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/enrich`;
1206
+ try {
1207
+ if (!confirm) {
1208
+ const r = await post(path, { ...sel, preview: true });
1209
+ const lines = [
1210
+ `ENRICH PREVIEW — list ${lead_list_id}`,
1211
+ ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} already on file (free) · ${r.no_identifier ?? 0} no identifier`,
1212
+ ` provider: ${r.provider || "—"}`,
1213
+ ` estimated cost: ${fmtCost(r.cost)}`,
1214
+ "",
1215
+ r.chargeable
1216
+ ? "Report this to the user. To run it, call enrich_leads again with the same selection and confirm:true."
1217
+ : "Nothing chargeable to enrich.",
1218
+ ];
1219
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1220
+ }
1221
+ const r = await post(path, { ...sel, background: true });
1222
+ return { content: [{ type: "text", text:
1223
+ `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.` }] };
1224
+ } catch (e) {
1225
+ return { content: [{ type: "text", text: `Couldn't enrich: ${e.message}` }] };
1002
1226
  }
1003
- const r = await get("/v2/people/coverage", { title, keyword, stale_days });
1004
- const lines = [
1005
- `COVERAGE — ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
1006
- ` ${r.total ?? 0} already in your workspace`,
1007
- ` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
1008
- ` ${r.fresh_verified ?? 0} have a fresh verified email`,
1009
- ];
1010
- const sample = r.sample || [];
1011
- if (sample.length) {
1012
- lines.push("", "SAMPLE (oldest first):");
1013
- for (const s of sample.slice(0, 12)) {
1014
- lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
1227
+ }
1228
+ );
1229
+
1230
+ server.tool(
1231
+ "verify_leads",
1232
+ "(Nous Cloud only) Validate email deliverability for leads in a lead list, on the workspace's own MillionVerifier / " +
1233
+ "NeverBounce key. ALWAYS two steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, " +
1234
+ "connected verifiers, $ estimate) report it to the user — then call again with confirm:true to run as " +
1235
+ "a background job. Defaults to every UNVERIFIED email (has an address, no verdict yet); narrow with " +
1236
+ "`filter` or pass `ids`. If no verifier is connected it says so — tell the user to add a MillionVerifier " +
1237
+ "or NeverBounce key in Integrations.",
1238
+ {
1239
+ lead_list_id: z.string().describe("The lead list's UUID."),
1240
+ filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all unverified emails."),
1241
+ ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
1242
+ provider: z.enum(["millionverifier", "neverbounce"]).optional().describe("Which verifier to use. Defaults to MillionVerifier, then NeverBounce."),
1243
+ confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only. true = actually run it as a background job."),
1244
+ },
1245
+ async ({ lead_list_id, filter, ids, provider, confirm }) => {
1246
+ const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "unverified" } };
1247
+ const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/verify`;
1248
+ try {
1249
+ if (!confirm) {
1250
+ const r = await post(path, { ...sel, provider, preview: true });
1251
+ const lines = [
1252
+ `VERIFY PREVIEW — list ${lead_list_id}`,
1253
+ ` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} recently verified (free) · ${r.no_email ?? 0} no email`,
1254
+ ` verifier: ${r.provider || "—"}${r.connected_verifiers ? ` (connected: ${r.connected_verifiers.join(", ") || "none"})` : ""}`,
1255
+ ` estimated cost: ${fmtCost(r.cost)}`,
1256
+ "",
1257
+ r.chargeable
1258
+ ? "Report this to the user. To run it, call verify_leads again with the same selection and confirm:true."
1259
+ : "Nothing chargeable to verify.",
1260
+ ];
1261
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1015
1262
  }
1263
+ const r = await post(path, { ...sel, provider, background: true });
1264
+ return { content: [{ type: "text", text:
1265
+ `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.` }] };
1266
+ } catch (e) {
1267
+ const msg = /no_verifier_connected/.test(e.message)
1268
+ ? "No email verifier is connected. Tell the user to add a MillionVerifier or NeverBounce API key in Integrations, then try again."
1269
+ : `Couldn't verify: ${e.message}`;
1270
+ return { content: [{ type: "text", text: msg }] };
1016
1271
  }
1017
- return { content: [{ type: "text", text: lines.join("\n") }] };
1018
1272
  }
1019
1273
  );
1020
1274