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