@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.
- package/package.json +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.
|
|
4
|
-
"description": "Nous
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
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
|
|
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(` ${
|
|
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(` ${
|
|
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 ${
|
|
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(` ${
|
|
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 —
|
|
343
|
-
"
|
|
344
|
-
"
|
|
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
|
-
|
|
354
|
-
|
|
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,
|
|
550
|
-
"by you, the agent
|
|
551
|
-
"walk the user through it
|
|
552
|
-
"(1)
|
|
553
|
-
"(
|
|
554
|
-
"
|
|
555
|
-
"
|
|
556
|
-
"
|
|
557
|
-
"
|
|
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:
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
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
|
-
"
|
|
936
|
-
"
|
|
937
|
-
"
|
|
938
|
-
"
|
|
939
|
-
"Returns
|
|
940
|
-
"
|
|
941
|
-
"
|
|
942
|
-
"
|
|
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("
|
|
945
|
-
linkedin_urls: z.array(z.string()).optional().describe("
|
|
946
|
-
domains: z.array(z.string()).optional().describe("
|
|
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 mode — company 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
|
|
950
|
-
|
|
951
|
-
if (
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
982
|
-
// The
|
|
983
|
-
//
|
|
984
|
-
//
|
|
1167
|
+
// TOOLS: enrich_leads / verify_leads — POST /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
|
-
"
|
|
988
|
-
"
|
|
989
|
-
"
|
|
990
|
-
"
|
|
991
|
-
"
|
|
992
|
-
"
|
|
993
|
-
"
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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 ({
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
|