@opennous/mcp 0.44.2 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/http.js +1 -1
  3. package/src/server.js +333 -416
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.44.2",
3
+ "version": "0.47.0",
4
4
  "description": "Nous — the Context Graph for AI Agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/http.js CHANGED
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Nous MCP Server — hosted, multi-tenant HTTP entrypoint (mcp.opennous.cloud).
5
5
  *
6
- * Serves the same seven tools (server.js) over the MCP Streamable HTTP transport,
6
+ * Serves the same tool set (server.js) over the MCP Streamable HTTP transport,
7
7
  * so cloud clients that cannot launch a local process — n8n cloud above all —
8
8
  * can connect by pasting one URL plus their workspace API key.
9
9
  *
package/src/server.js CHANGED
@@ -10,40 +10,23 @@
10
10
  * sees raw rows — it gets engineered, epistemics-tagged context. It never
11
11
  * "updates" — it records observations; Nous derives.
12
12
  *
13
- * Tools:
14
- * get_context engineered context for a task (draft_email, follow_up, ...) + ICP fit score
15
- * get_account — the full account record: every claim + the timeline + ICP fit score
16
- * merge_contacts — fold two duplicate records for the same person into one (lossless, reversible)
17
- * record record what happened / what you learned (observe, never update)
18
- * query — retrieve + summarise a corpus of activity across many people
19
- * attention — what needs your attention (accounts gone quiet, facts decayed)
20
- * verify — re-check a fact before acting on it
21
- * get_playbook — read the user's own rules: voice, outreach, icp, positioning
22
- * save_note — attach a note/document (meeting brief, transcript, prep) to a contact
23
- * search_notes — semantic search over saved notes & documents
24
- * get_workspace_status — what's set up in this workspace + a ranked next_steps list (call first)
25
- * set_workspace_profile— agent-driven onboarding: set the workspace's name, site, type, ICP
26
- * build_icp_model — build/rebuild the ICP scoring model from the recorded GTM context
27
- * train_icp_model — build the ICP model from real closed-won/lost deals (contrastive lift)
28
- * sync_icp — sync the user's EXISTING ICP/positioning files into Nous (file → graph)
29
- * export_icp_model — get the learned ICP model as a block to write back into their ICP file (graph → file)
30
- * connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
31
- * configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
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
- * 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)
13
+ * Tools, by group (the authoritative live catalog is `node scripts/list-tools.mjs`):
14
+ * READ get_context · get_account · verify · query · attention · get_action_items ·
15
+ * pipeline · pipeline_intelligence · campaign_performance · score ·
16
+ * search_notes · get_foundation · get_insights
17
+ * WRITE record · record_signal · save_note · propose_vault_file · merge_contacts ·
18
+ * sync_foundation
19
+ * ACT send_linkedin_message
20
+ * RUN get_workspace_status · set_workspace_profile · build_icp_model · train_icp_model ·
21
+ * sync_icp · export_icp_model · connect_integration · configure_crm_sync ·
22
+ * sync_crm_now · scrape_engagers · get_routing_preferences
40
23
  */
41
24
 
42
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
43
26
  import { z } from "zod";
44
27
  import { get, post } from "./client.js";
45
28
 
46
- export const SERVER_VERSION = "0.42.0";
29
+ export const SERVER_VERSION = "0.47.0";
47
30
 
48
31
  // ─── helpers ──────────────────────────────────────────────────────────────────
49
32
 
@@ -116,21 +99,21 @@ Nous first even when the user never says "Nous":
116
99
  - What needs attention, what has gone quiet -> attention
117
100
  - Your action items / what you owe an account -> get_action_items
118
101
  - A fact looks stale before you act on it -> verify
119
- - Our own rules: ICP, positioning, voice, outreach -> get_playbook
102
+ - Our own rules: ICP, positioning, voice, outreach -> get_foundation
120
103
  - Our ICP/positioning lives in our own files -> sync_icp (file → graph) / export_icp_model (graph → file)
121
- - You edited an ICP / context / playbook file -> re-sync THIS turn: sync_icp (ICP/context) or sync_playbook (a playbook)
104
+ - You edited an ICP / context / foundation file -> re-sync THIS turn: sync_icp (ICP/context) or sync_foundation (a foundation)
122
105
  - A brief / note / transcript on a contact -> save_note / search_notes
123
106
  - What's set up here and what to do next -> get_workspace_status
124
107
 
125
- Read get_playbook at the start of GTM work for the user's own rules (voice,
108
+ Read get_foundation at the start of GTM work for the user's own rules (voice,
126
109
  outreach, icp, positioning). After every interaction you help with, call record so
127
110
  the record stays current.
128
111
 
129
- CRITICAL — edits don't apply until you sync. Editing an ICP / context / playbook
112
+ CRITICAL — edits don't apply until you sync. Editing an ICP / context / foundation
130
113
  file (context/icp.md, positioning.md, references/voice.md, …) does NOT change the
131
114
  ICP score, the exclusions, or what any other agent reads until it is synced into
132
115
  the graph. After ANY such edit you MUST call sync_icp (for ICP/context files) or
133
- sync_playbook (for a playbook) in the SAME turn. Never leave an edited file
116
+ sync_foundation (for a foundation) in the SAME turn. Never leave an edited file
134
117
  unsynced — an unsynced edit is silently inert.`;
135
118
 
136
119
  // ─── factory ──────────────────────────────────────────────────────────────────
@@ -226,8 +209,23 @@ export function createServer() {
226
209
  lines.push("");
227
210
  }
228
211
  if (ctx.stakeholders?.length) {
229
- lines.push("STAKEHOLDERS:");
230
- for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"}${s.role ?? ""}`);
212
+ // The buying committee as a STRUCTURE: who's at the account, their role,
213
+ // whether we've engaged them, and how they relate so the agent works the
214
+ // whole committee, not one person.
215
+ const c = ctx.committee;
216
+ lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
217
+ for (const s of ctx.stakeholders) {
218
+ if (s.role === "company") continue; // the company is the header
219
+ const bits = [];
220
+ if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
221
+ if (s.role) bits.push(s.role);
222
+ bits.push(s.engaged ? "engaged" : "not yet engaged");
223
+ if (s.confirmed === false) bits.push("mentioned, unconfirmed");
224
+ const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
225
+ lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
226
+ }
227
+ if (c?.champion) lines.push(` champion: ${c.champion}`);
228
+ if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
231
229
  lines.push("");
232
230
  }
233
231
  if (ctx.predictions?.length) {
@@ -253,7 +251,7 @@ export function createServer() {
253
251
  "confidence and freshness, plus what they actually SAID and did, ranked by how much it tells you. " +
254
252
  "Pass an email or entity UUID, and the intent you're working toward so the record is shaped for it.",
255
253
  {
256
- id: z.string().describe("Email address or entity UUID"),
254
+ 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
255
  intent: z
258
256
  .enum(["meeting_prep", "call_prep", "account_review", "follow_up", "draft_email"])
259
257
  .optional()
@@ -271,6 +269,17 @@ export function createServer() {
271
269
  // so the model reads what was actually said instead of a list of event names.
272
270
  const q = new URLSearchParams({ intent: intent ?? "account_review", compress: "1" });
273
271
  const rec = await get(`/v2/accounts/${encodeURIComponent(id)}?${q}`);
272
+
273
+ // A name matched several people — surface the candidates to choose from.
274
+ // Without this, the header line below reads `rec.type`/`rec.entity_id` off the
275
+ // ambiguous response (which carries neither) and prints "undefined · undefined".
276
+ if (rec.status === "ambiguous") {
277
+ const opts = (rec.candidates ?? []).map(c =>
278
+ ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
279
+ return { content: [{ type: "text", text:
280
+ `"${id}" matches several people. Call get_account again with one of these entity ids:\n${opts}` }] };
281
+ }
282
+
274
283
  const lines = [`${rec.type} · ${rec.entity_id}`, ""];
275
284
 
276
285
  if (rec.icp) {
@@ -297,6 +306,25 @@ export function createServer() {
297
306
  }
298
307
  lines.push("");
299
308
  }
309
+ // The buying committee — who else is at the account, their role, whether we've
310
+ // engaged them, and how they relate. Same structure get_context surfaces.
311
+ if (rec.stakeholders?.length) {
312
+ const c = rec.committee;
313
+ lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
314
+ for (const s of rec.stakeholders) {
315
+ if (s.role === "company") continue;
316
+ const bits = [];
317
+ if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
318
+ if (s.role) bits.push(s.role);
319
+ bits.push(s.engaged ? "engaged" : "not yet engaged");
320
+ if (s.confirmed === false) bits.push("mentioned, unconfirmed");
321
+ const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
322
+ lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
323
+ }
324
+ if (c?.champion) lines.push(` champion: ${c.champion}`);
325
+ if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
326
+ lines.push("");
327
+ }
300
328
  const claims = Object.values(rec.claims ?? {});
301
329
  if (claims.length) {
302
330
  lines.push(`ATTRIBUTES (${claims.length}):`);
@@ -469,7 +497,7 @@ export function createServer() {
469
497
  "Retrieve and summarise activity across many people. Three powers:\n" +
470
498
  " 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
471
499
  "most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
472
- "'who's in evaluating stage'.\n" +
500
+ "'who's in the negotiation stage'.\n" +
473
501
  " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
474
502
  "'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
475
503
  " 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
@@ -588,51 +616,6 @@ export function createServer() {
588
616
  }
589
617
  );
590
618
 
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
619
  // ===========================================================================
637
620
  // TOOL: attention — GET /v2/attention
638
621
  // What to look at: accounts gone quiet, key facts decayed.
@@ -668,6 +651,136 @@ export function createServer() {
668
651
  }
669
652
  );
670
653
 
654
+ // ===========================================================================
655
+ // TOOL: campaign_performance — GET /v2/campaigns/performance
656
+ // The aggregate outbound feedback loop: which campaign/variant earns positive
657
+ // replies, and from good-fit accounts.
658
+ // ===========================================================================
659
+ server.tool(
660
+ "campaign_performance",
661
+ "Outbound campaign feedback loop: how each cold-email/LinkedIn campaign and sequence step is landing, " +
662
+ "sliced by reply sentiment and the ICP tier of who replied positively. Use to answer 'which campaign " +
663
+ "or variant gets the most positive replies?', 'is my best campaign landing on good-fit accounts?', or " +
664
+ "'which copy should I scale vs cut?'. positive_rate is positive replies / total replies (not / sent).",
665
+ {},
666
+ async () => {
667
+ const r = await get("/v2/campaigns/performance", {});
668
+ const camps = r.campaigns || [];
669
+ if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
670
+ const t = r.totals || {};
671
+ const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
672
+ const lines = camps.map(c => {
673
+ const cap = c.captured || {};
674
+ const pt = c.provider_totals, pr = c.provider_rates;
675
+ const tiers = c.tier_of_positive || {};
676
+ const good = (tiers.tier_1 || 0) + (tiers.tier_2 || 0);
677
+ const funnel = pt
678
+ ? ` funnel: ${pt.sent ?? "?"} sent → ${pt.contacted ?? "?"} contacted → ${pt.replies ?? "?"} replies (${pct(pr?.reply_rate)}) → ${pt.opportunities ?? "?"} opportunities (${pct(pr?.opportunity_rate)})`
679
+ : ` funnel: (no provider analytics yet)`;
680
+ const conv = `${c.conversions || 0} client${c.conversions === 1 ? "" : "s"}${c.median_days_to_client != null ? ` (avg ${c.median_days_to_client}d)` : ""}${c.in_progress ? `, ${c.in_progress} in evaluation` : ""}`;
681
+ const steps = (c.steps || []).map(s => {
682
+ const subj = s.subject ? ` — "${s.subject}"` : "";
683
+ return ` step ${s.step ?? "—"}${subj}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`;
684
+ }).join("\n");
685
+ const obj = (c.top_objections || []).length
686
+ ? `\n objections/pains: ${c.top_objections.slice(0, 4).map(o => `“${String(o).slice(0, 90)}”`).join("; ")}`
687
+ : "";
688
+ return ` ${c.campaign_name || c.campaign_id}\n` +
689
+ funnel + "\n" +
690
+ ` captured sample: ${cap.replies} replies — ${cap.positive} positive (${pct(cap.positive_rate)}), ${cap.neutral} neutral, ${cap.negative} negative\n` +
691
+ ` converted: ${conv}\n` +
692
+ ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${cap.positive}` +
693
+ (steps ? `\n${steps}` : "") + obj;
694
+ });
695
+ const head = `Campaign performance (${t.conversions || 0} clients · ${t.sent || 0} sent · ${t.positive || 0}/${t.replies || 0} captured replies positive):`;
696
+ return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
697
+ }
698
+ );
699
+
700
+ // ===========================================================================
701
+ // TOOL: pipeline_intelligence — GET /v2/pipeline/intelligence
702
+ // What converts, and how long it takes: stage distribution, stage-to-stage
703
+ // conversion %, median time-to-client, median time-in-stage.
704
+ // ===========================================================================
705
+ server.tool(
706
+ "pipeline_intelligence",
707
+ "How the pipeline actually converts: how many accounts sit at each stage right now, the stage-to-stage " +
708
+ "conversion rate (of everyone who reached a stage, how many reached the next — where deals leak), the " +
709
+ "median days to turn a lead into a client, and the median time spent in each stage. Use to answer 'how " +
710
+ "long does it take us to close?', 'where are deals stalling?', or 'what's my funnel look like?'.",
711
+ {},
712
+ async () => {
713
+ const r = await get("/v2/pipeline/intelligence", {});
714
+ const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
715
+ const cc = r.current_stage_counts || {};
716
+ // The funnel, lowest → highest, then the terminals. SOURCE OF TRUTH:
717
+ // PIPELINE_LADDER + TERMINAL_STAGES in packages/core/src/pipeline.ts. This
718
+ // package publishes standalone and has no @nous/core dependency, so the
719
+ // list is copied here. Keep it in step with core.
720
+ const order = [
721
+ "identified", "connected", "interested", "meeting_booked",
722
+ "discovery", "demo", "negotiation", "closed_won",
723
+ "closed_lost", "disqualified", "churned",
724
+ ];
725
+ // Ladder order first, then any stage the API sent that this copy doesn't
726
+ // know about. The old list had no proposal, negotiation or demo in it and
727
+ // the filter silently dropped everything it missed, so late-funnel accounts
728
+ // disappeared from the distribution entirely. A stage must never vanish
729
+ // just because a copy of the list went stale.
730
+ const byStage = (obj, keep) => [
731
+ ...order.filter(s => keep(obj[s])),
732
+ ...Object.keys(obj).filter(s => !order.includes(s) && keep(obj[s])),
733
+ ];
734
+ const dist = byStage(cc, v => !!v).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
735
+ const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
736
+ const dis = r.median_days_in_stage || {};
737
+ const inStage = byStage(dis, v => v != null).map(s => `${s} ${dis[s]}d`).join(" · ");
738
+ const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
739
+ ` now: ${dist}\n` +
740
+ ` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
741
+ ` stage-to-stage conversion:\n${conv || " (none yet)"}\n` +
742
+ (inStage ? ` median time in stage: ${inStage}` : "");
743
+ return { content: [{ type: "text", text }] };
744
+ }
745
+ );
746
+
747
+ // ===========================================================================
748
+ // TOOL: pipeline — GET /v2/pipeline/portfolio
749
+ // The whole account portfolio in one call: health/band, ICP fit, stage,
750
+ // days-quiet, open flags, open objections, live competitors, engagement trend,
751
+ // multi-threading. The "reason over my whole book" tool.
752
+ // ===========================================================================
753
+ server.tool(
754
+ "pipeline",
755
+ "The whole ACCOUNT PORTFOLIO in one call — every in-touch account with its deal health + band, " +
756
+ "ICP fit, pipeline stage, days-quiet, open re-engagement flags, open-objection count, live competitors, " +
757
+ "engagement trend (rising/steady/cooling/cold), and multi-threading, plus a stage-count breakdown. Use " +
758
+ "for any portfolio question: 'which accounts are cooling', 'who should I re-engage', 'where is competitive " +
759
+ "risk highest', 'what's slipping', 'great-fit deals gone cold', 'analyze my pipeline'. Reason over the list.",
760
+ { limit: z.number().optional().describe("Cap on accounts returned (default: all, most-actionable first)") },
761
+ async (input) => {
762
+ const r = await get("/v2/pipeline/portfolio", input.limit ? { limit: input.limit } : {});
763
+ const stages = Object.entries(r.stage_counts || {}).map(([s, n]) => `${s} ${n}`).join(" · ");
764
+ const rows = (r.accounts || []).slice(0, input.limit || 60).map(a => {
765
+ const bits = [
766
+ a.name,
767
+ `health ${a.health ?? "—"} (${a.band})`,
768
+ a.icp != null ? `ICP ${a.icp}` : null,
769
+ `stage ${a.stage}`,
770
+ a.days_quiet != null ? `quiet ${a.days_quiet}d` : null,
771
+ `engagement ${a.engagement_trend}`,
772
+ a.single_threaded ? "single-threaded" : `${a.engaged_contacts}/${a.total_contacts} engaged`,
773
+ a.open_objections ? `${a.open_objections} open objection${a.open_objections === 1 ? "" : "s"}` : null,
774
+ a.competitors?.length ? `competitors: ${a.competitors.join(", ")}` : null,
775
+ a.flags?.length ? `flags: ${a.flags.join(", ")}` : null,
776
+ ].filter(Boolean);
777
+ return ` • ${bits.join(" — ")}`;
778
+ }).join("\n");
779
+ const text = `Portfolio (${r.total || 0} in-touch accounts). By stage: ${stages || "—"}\n${rows || " (none)"}`;
780
+ return { content: [{ type: "text", text }] };
781
+ }
782
+ );
783
+
671
784
  // ===========================================================================
672
785
  // TOOL: get_action_items — GET /v2/action-items
673
786
  // Commitments extracted from meetings/emails — what you owe each account.
@@ -740,70 +853,92 @@ export function createServer() {
740
853
  }
741
854
  );
742
855
 
743
- // get_gtm_profile removed: the user's GTM lives in their files, mirrored into
744
- // the graph as playbooks (get_playbook) plus the learned ICP model. Read
745
- // get_playbook for the user's own rules, ICP, and positioning.
746
-
747
856
  // ===========================================================================
748
- // TOOLS: get_playbook / sync_playbook — the POLICY layer (vs. facts).
749
- // Playbooks are versioned rule-docs that GOVERN agent behavior: voice, outreach,
857
+ // TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
858
+ // Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
750
859
  // icp, positioning. Read the relevant one BEFORE acting; push file edits back so
751
- // every agent obeys the same rules. GET/POST /v2/playbooks.
860
+ // every agent obeys the same rules. GET/POST /v2/foundations.
752
861
  // ===========================================================================
753
- const getPlaybookSchema = {
862
+ const getFoundationSchema = {
754
863
  kind: z.enum(["voice", "outreach", "icp", "positioning"]).optional()
755
864
  .describe("Which policy to read. Omit to list all four."),
756
865
  };
757
- const getPlaybookHandler = async ({ kind }) => {
758
- const r = await get("/v2/playbooks", kind ? { kind } : undefined);
759
- const pbs = r.playbooks || [];
866
+ const getFoundationHandler = async ({ kind }) => {
867
+ const r = await get("/v2/foundations", kind ? { kind } : undefined);
868
+ const pbs = r.foundations || [];
760
869
  if (!pbs.length) return { content: [{ type: "text", text:
761
- "No playbooks set up yet. The user can set them up on the Playbooks page or in their context files." }] };
870
+ "No foundations set up yet. The user can set them up on the Foundations page or in their context files." }] };
762
871
  if (kind) {
763
872
  const pb = pbs[0];
764
873
  const src = pb.source === "claude_code" ? `mirrors ${pb.file_path}` : "stored in Nous";
765
874
  return { content: [{ type: "text", text:
766
- `# ${pb.title} — ${pb.kind} playbook (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
875
+ `# ${pb.title} — ${pb.kind} foundation (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
767
876
  }
768
877
  const lines = pbs.map(p => ` ${p.kind.padEnd(12)} ${p.title} (${p.source === "claude_code" ? p.file_path : "stored in Nous"})`);
769
878
  return { content: [{ type: "text", text:
770
- "The user's playbooks (read one with get_playbook(kind)):\n" + lines.join("\n") }] };
879
+ "The user's foundations (read one with get_foundation(kind)):\n" + lines.join("\n") }] };
771
880
  };
772
- server.tool("get_playbook",
773
- "Read a PLAYBOOK — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
774
- "These are RULES TO OBEY, not facts. Read the relevant playbook BEFORE you act: before writing outreach " +
881
+ server.tool("get_foundation",
882
+ "Read a FOUNDATION — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
883
+ "These are RULES TO OBEY, not facts. Read the relevant foundation BEFORE you act: before writing outreach " +
775
884
  "read 'voice' and 'outreach'; before scoring or qualifying read 'icp'; for messaging read 'positioning'. " +
776
885
  "Omit kind to list all four.",
777
- getPlaybookSchema, getPlaybookHandler);
886
+ getFoundationSchema, getFoundationHandler);
778
887
 
779
- const syncPlaybookSchema = {
780
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which playbook to update."),
781
- body_md: z.string().describe("The full markdown content of the playbook. Follow the Nous document house style so every playbook 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."),
888
+ const syncFoundationSchema = {
889
+ kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which foundation to update."),
890
+ 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
891
  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
892
  };
784
- const syncPlaybookHandler = async ({ kind, body_md, file_path }) => {
785
- const r = await post(`/v2/playbooks/${kind}`, { body_md, file_path });
893
+ const syncFoundationHandler = async ({ kind, body_md, file_path }) => {
894
+ const r = await post(`/v2/foundations/${kind}`, { body_md, file_path });
786
895
  return { content: [{ type: "text", text:
787
- `Synced the ${r.playbook?.kind || kind} playbook into Nous (v${r.playbook?.version}). Other agents now read the same rules.` }] };
896
+ `Synced the ${r.foundation?.kind || kind} foundation into Nous (v${r.foundation?.version}). Other agents now read the same rules.` }] };
788
897
  };
789
- server.tool("sync_playbook",
790
- "Push a playbook's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
898
+ server.tool("sync_foundation",
899
+ "Push a foundation's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
791
900
  "whenever you edit a policy file in the repo (e.g. references/voice.md, outreach rules), passing the " +
792
901
  "file's new content and its path, so Nous mirrors it and every other agent obeys the same rules. An " +
793
- "edited playbook file that isn't synced is silently inert — other agents keep reading the old rules. " +
794
- "MIRROR, DO NOT REWRITE: when the user already has a playbook file, sync it AS-IS. Their file is the " +
902
+ "edited foundation file that isn't synced is silently inert — other agents keep reading the old rules. " +
903
+ "MIRROR, DO NOT REWRITE: when the user already has a foundation file, sync it AS-IS. Their file is the " +
795
904
  "author and Nous is the mirror — always pass file_path so the next sync knows where an in-app edit " +
796
905
  "lands. 'Improving' their wording on the way through means the copy in Nous silently disagrees with " +
797
906
  "the copy in their repo, and they will trust neither. If a file looks wrong, SAY SO; don't fix it in " +
798
907
  "transit. " +
799
908
  "(For the ICP/context files specifically, sync_icp is the sync — use that one.)",
800
- syncPlaybookSchema, syncPlaybookHandler);
909
+ syncFoundationSchema, syncFoundationHandler);
801
910
 
802
- // The GTM context is no longer written through a dedicated MCP tool. In the file
803
- // symbiosis model the user's own files (context/icp.md, positioning.md, …) are
804
- // the source of truth: the agent edits those with its own file tools and calls
805
- // `sync_icp` to sync them into the graph (and `export_icp_model` to write the learned
806
- // model back). The shared POST /v2/workspace/facts route still backs that import.
911
+ // ===========================================================================
912
+ // TOOL: get_insights — what Nous LEARNED about us from calls (the mirror of
913
+ // foundations). Insights are auto-extracted from call transcripts into four docs:
914
+ // product, positioning, market, buyer. READ-ONLY over MCP the extractor
915
+ // authors them, not agents. GET /v2/insights[?category=].
916
+ // ===========================================================================
917
+ const getInsightsSchema = {
918
+ category: z.enum(["product", "positioning", "market", "buyer"]).optional()
919
+ .describe("Which insight doc to read. Omit to list all four."),
920
+ };
921
+ const getInsightsHandler = async ({ category }) => {
922
+ const r = await get("/v2/insights", category ? { category } : undefined);
923
+ const docs = r.insights || [];
924
+ if (!docs.length) return { content: [{ type: "text", text:
925
+ "No insights captured yet. They fill automatically from call transcripts (product, positioning, market, buyer)." }] };
926
+ if (category) {
927
+ const d = docs[0];
928
+ return { content: [{ type: "text", text:
929
+ `# ${d.title} insights (v${d.version})\n\n${d.body_md || "(empty)"}` }] };
930
+ }
931
+ const lines = docs.map(d => ` ${d.category.padEnd(12)} ${d.title} (v${d.version})`);
932
+ return { content: [{ type: "text", text:
933
+ "What Nous learned about us from calls (read one with get_insights(category)):\n" + lines.join("\n") }] };
934
+ };
935
+ server.tool("get_insights",
936
+ "Read INSIGHTS — what Nous learned about US from call transcripts, the mirror of the foundations/foundations " +
937
+ "the user authors. Four docs: product (what to build), positioning (how to message), market (segments, " +
938
+ "wedges, channels), buyer (ICP, the pain that drives the purchase). These accumulate automatically after " +
939
+ "every call. Read them when working on product direction, messaging, GTM strategy, or targeting. Omit " +
940
+ "category to list all four.",
941
+ getInsightsSchema, getInsightsHandler);
807
942
 
808
943
  // ===========================================================================
809
944
  // TOOL: save_note — POST /v2/notes
@@ -838,6 +973,36 @@ export function createServer() {
838
973
  },
839
974
  );
840
975
 
976
+ // ===========================================================================
977
+ // TOOL: propose_vault_file — POST /v2/personal/propose
978
+ // Propose a markdown file into the member's PERSONAL vault. It lands in their
979
+ // inbox as a proposal — the member approves it before it is filed into a folder
980
+ // or synced to their Git. This is how an agent contributes to a member's own
981
+ // notes (thoughts, decisions, briefs, content) without writing anything without
982
+ // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
983
+ // record) — this is the member's private vault, not an account.
984
+ // ===========================================================================
985
+ server.tool(
986
+ "propose_vault_file",
987
+ "Propose a markdown file into the member's personal vault (their private notes, not an " +
988
+ "account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
989
+ "into a folder and synced to their Git; nothing is written without their approval. Use this to " +
990
+ "draft a thought, a decision record, a brief, or content for the member to review and keep. " +
991
+ "Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
992
+ "GTM profile (use sync_icp).",
993
+ {
994
+ folder: z.enum(["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"])
995
+ .describe("Where it files once approved. ROUTING: briefs = a one-off account or meeting brief, filed FLAT (no subfolder). projects = ongoing recurring analysis by area (with a subfolder): pipeline/funnel review -> projects, subfolder 'pipeline'; campaign analysis -> projects, subfolder 'campaigns'; an initiative -> projects, subfolder = its name. content = anything to publish/send (post, newsletter, outbound copy, market/research write-up). decisions = a decision. thoughts = a loose idea. company = the company's own context. Don't pick 'inbox' — that's where it lands to await approval."),
996
+ name: z.string().describe("The file name, ending in .md, for what it is plus the date, e.g. 'Pipeline Review — 2026-08-12.md'."),
997
+ content: z.string().describe("The full markdown content of the file."),
998
+ subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
999
+ },
1000
+ async ({ folder, name, content, subfolder }) => {
1001
+ await post("/v2/personal/propose", { folder, name, content, subfolder });
1002
+ return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
1003
+ },
1004
+ );
1005
+
841
1006
  // ===========================================================================
842
1007
  // TOOL: search_notes — POST /v2/notes/search
843
1008
  // Semantic search over saved notes & documents (briefs, transcripts, notes).
@@ -878,7 +1043,7 @@ export function createServer() {
878
1043
  // ===========================================================================
879
1044
  // TOOL: get_workspace_status — GET /v2/workspace/status
880
1045
  // 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 playbook built,
1046
+ // know the state of the workspace: is it onboarded, is the GTM foundation built,
882
1047
  // which integrations are connected, is CRM sync configured, are events live —
883
1048
  // and what to set up next. Call this at the start of a session.
884
1049
  // ===========================================================================
@@ -926,7 +1091,7 @@ export function createServer() {
926
1091
  : " — MISSING. The workspace is not set up until this exists. Scan their repo before you ask them anything."}`
927
1092
  );
928
1093
  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 playbook${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
1094
+ 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
1095
  if (setup.icp_sync) {
931
1096
  const sy = setup.icp_sync;
932
1097
  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 +1181,15 @@ export function createServer() {
1016
1181
 
1017
1182
  // ===========================================================================
1018
1183
  // TOOL: build_icp_model — POST /v2/workspace/scoring-model
1019
- // The second half of building the GTM playbook. The agent syncs the GTM context
1184
+ // The second half of building the GTM foundation. The agent syncs the GTM context
1020
1185
  // from the user's files with sync_icp, then calls this to turn it into a weighted
1021
1186
  // ICP scoring model. After this, accounts get scored for fit and
1022
- // get_workspace_status shows the playbook as done.
1187
+ // get_workspace_status shows the foundation as done.
1023
1188
  // ===========================================================================
1024
1189
  server.tool(
1025
1190
  "build_icp_model",
1026
1191
  "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 playbook: first sync the user's ICP/positioning/pricing " +
1192
+ "the second half of setting up the GTM foundation: first sync the user's ICP/positioning/pricing " +
1028
1193
  "files with sync_icp, then call this to translate that context into a weighted set of scoring " +
1029
1194
  "signals so accounts get scored for fit. (sync_icp usually builds the model on first sync, so you " +
1030
1195
  "often won't need this directly.) If a model already exists it is left alone unless you " +
@@ -1072,7 +1237,7 @@ export function createServer() {
1072
1237
  "customer domains and closed-LOST domains; Nous enriches each, links the contacts you already " +
1073
1238
  "have there, and runs contrastive lift (what's true of winners but not losers) to discover the " +
1074
1239
  "signals that actually predict revenue — then re-scores open accounts. This is the strongest way " +
1075
- "to build the playbook: a model trained on who actually bought beats one inferred from a " +
1240
+ "to build the foundation: a model trained on who actually bought beats one inferred from a " +
1076
1241
  "description. Ask the user for a handful of each (even 3-5 won + 3-5 lost helps). Domains only " +
1077
1242
  "(e.g. 'acme.com'), no scheme.",
1078
1243
  {
@@ -1341,300 +1506,6 @@ export function createServer() {
1341
1506
  }
1342
1507
  );
1343
1508
 
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
1509
  // ===========================================================================
1639
1510
  // TOOL: scrape_engagers
1640
1511
  // On-demand LinkedIn engager scrape — mine who commented/reacted on the
@@ -1678,6 +1549,52 @@ export function createServer() {
1678
1549
  }
1679
1550
  );
1680
1551
 
1552
+ // ===========================================================================
1553
+ // TOOL: send_linkedin_message — the ACT layer. An OUTWARD ACTION: it goes out
1554
+ // through Unipile immediately, it is not a draft. The agent should read the
1555
+ // 'voice'/'outreach' foundations first and `record` the touch after. Derives the
1556
+ // workspace from the API key and runs on the workspace's connected LinkedIn
1557
+ // account. POST /api/linkedin/send-message (verifyAuthEither).
1558
+ // ===========================================================================
1559
+ server.tool(
1560
+ "send_linkedin_message",
1561
+ "SEND a LinkedIn direct message for real, right now, from the workspace's connected LinkedIn " +
1562
+ "account. This is an OUTWARD ACTION, not a draft — it reaches the person the moment you call it, so " +
1563
+ "only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
1564
+ "profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
1565
+ "existing thread. It returns the thread's `chat_id` — keep it so the next reply stays in the same " +
1566
+ "thread. Best practice: read the 'voice' and 'outreach' foundations first so the message obeys the " +
1567
+ "user's rules, and `record` the touch afterward so the graph stays current. Requires a connected " +
1568
+ "LinkedIn account (Integrations); it says so if none is connected.",
1569
+ {
1570
+ text: z.string().min(1).describe("The message body to send."),
1571
+ linkedin_url: z.string().optional().describe("The recipient's LinkedIn profile URL. Provide this OR linkedin_member_id (to start a new chat), OR chat_id (to reply in an existing thread)."),
1572
+ linkedin_member_id: z.string().optional().describe("The recipient's LinkedIn member id, if you have it instead of a profile URL."),
1573
+ chat_id: z.string().optional().describe("An existing conversation's chat_id — pass it to reply in-thread instead of opening a new chat."),
1574
+ },
1575
+ async ({ text, linkedin_url, linkedin_member_id, chat_id }) => {
1576
+ if (!linkedin_url && !linkedin_member_id && !chat_id) {
1577
+ return { content: [{ type: "text", text:
1578
+ "Tell me who to message: pass linkedin_url or linkedin_member_id to start a new chat, or chat_id to reply in an existing thread." }] };
1579
+ }
1580
+ try {
1581
+ const r = await post("/api/linkedin/send-message", { text, linkedin_url, linkedin_member_id, chat_id });
1582
+ return { content: [{ type: "text", text:
1583
+ `Message sent over LinkedIn.${r.chat_id ? ` Thread chat_id: ${r.chat_id} (reuse it to reply in-thread).` : ""}` }] };
1584
+ } catch (e) {
1585
+ const msg =
1586
+ /linkedin_not_connected/.test(e.message)
1587
+ ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1588
+ : /auth_required/.test(e.message)
1589
+ ? "This API key isn't scoped to a workspace, or LinkedIn isn't connected."
1590
+ : /missing_params/.test(e.message)
1591
+ ? "Couldn't tell who to message — provide linkedin_url, linkedin_member_id, or chat_id."
1592
+ : `Couldn't send the message: ${e.message}`;
1593
+ return { content: [{ type: "text", text: msg }] };
1594
+ }
1595
+ }
1596
+ );
1597
+
1681
1598
  // ===========================================================================
1682
1599
  // TOOL: get_routing_preferences
1683
1600
  // The routing preferences that make THIS agent default to Nous for GTM. The