@opennous/mcp 0.28.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.
- package/package.json +1 -1
- package/src/server.js +316 -108
package/package.json
CHANGED
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.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(` ${
|
|
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(` ${
|
|
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 ${
|
|
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(` ${
|
|
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 —
|
|
343
|
-
"
|
|
344
|
-
"
|
|
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
|
-
|
|
354
|
-
|
|
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,
|
|
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.",
|
|
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");
|
|
@@ -792,7 +864,7 @@ export function createServer() {
|
|
|
792
864
|
// ===========================================================================
|
|
793
865
|
server.tool(
|
|
794
866
|
"configure_crm_sync",
|
|
795
|
-
"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 " +
|
|
796
868
|
"CRM must already be connected (HubSpot/Pipedrive/Attio). Set any of: auto-sync (daily pull), " +
|
|
797
869
|
"push of touchpoints, the create policy (when a new record is auto-created and the ICP-fit " +
|
|
798
870
|
"threshold), and the hygiene cadence. Only send the fields you want to change. If it reports the " +
|
|
@@ -827,6 +899,39 @@ export function createServer() {
|
|
|
827
899
|
}
|
|
828
900
|
);
|
|
829
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
|
+
|
|
830
935
|
// ===========================================================================
|
|
831
936
|
// TOOL: set_trigger / list_triggers — /v2/workspace/triggers
|
|
832
937
|
// Outbound event triggers (webhooks) — wire the user's stack to fire when the
|
|
@@ -882,7 +987,7 @@ export function createServer() {
|
|
|
882
987
|
// ===========================================================================
|
|
883
988
|
server.tool(
|
|
884
989
|
"lead_list_operations",
|
|
885
|
-
"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, " +
|
|
886
991
|
"and classified replies — to report on what happened and attribute outcomes to a list's source. " +
|
|
887
992
|
"Call with NO lead_list_id to list the workspace's lead lists (id, name, count, source), then " +
|
|
888
993
|
"call again with an id. Filter with `event` (import | enrich | export | reply) and `days`. " +
|
|
@@ -924,97 +1029,200 @@ export function createServer() {
|
|
|
924
1029
|
);
|
|
925
1030
|
|
|
926
1031
|
// ===========================================================================
|
|
927
|
-
// TOOL:
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// 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.)
|
|
933
1037
|
// ===========================================================================
|
|
934
1038
|
server.tool(
|
|
935
|
-
"
|
|
936
|
-
"
|
|
937
|
-
"
|
|
938
|
-
"
|
|
939
|
-
"Returns
|
|
940
|
-
"
|
|
941
|
-
"
|
|
942
|
-
"
|
|
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.",
|
|
943
1051
|
{
|
|
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("
|
|
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 mode — company 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)."),
|
|
947
1058
|
},
|
|
948
|
-
async ({ emails, linkedin_urls, domains }) => {
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
if (
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
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." }] };
|
|
955
1065
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
+
}
|
|
974
1092
|
}
|
|
1093
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
975
1094
|
}
|
|
976
|
-
|
|
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)." }] };
|
|
977
1117
|
}
|
|
978
1118
|
);
|
|
979
1119
|
|
|
980
1120
|
// ===========================================================================
|
|
981
|
-
//
|
|
982
|
-
// The
|
|
983
|
-
//
|
|
984
|
-
//
|
|
1121
|
+
// TOOLS: enrich_leads / verify_leads — POST /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.
|
|
985
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
|
+
|
|
986
1143
|
server.tool(
|
|
987
|
-
"
|
|
988
|
-
"
|
|
989
|
-
"
|
|
990
|
-
"
|
|
991
|
-
"
|
|
992
|
-
"
|
|
993
|
-
"
|
|
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.",
|
|
994
1151
|
{
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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."),
|
|
998
1156
|
},
|
|
999
|
-
async ({
|
|
1000
|
-
|
|
1001
|
-
|
|
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}` }] };
|
|
1002
1180
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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") }] };
|
|
1015
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 }] };
|
|
1016
1225
|
}
|
|
1017
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1018
1226
|
}
|
|
1019
1227
|
);
|
|
1020
1228
|
|