@opennous/mcp 0.50.0 → 0.53.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/mcp",
3
- "version": "0.50.0",
3
+ "version": "0.53.0",
4
4
  "description": "Nous — the Context Graph for AI Agents.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -122,7 +122,10 @@ async function request(method, path, { body, query } = {}) {
122
122
  );
123
123
  }
124
124
  const errorMessage = err.error || err.message || res.statusText;
125
- throw new Error(`Nous API error (${res.status}): ${errorMessage}`);
125
+ // Append `detail` when present — it carries the actionable explanation (e.g. "reconnect
126
+ // the repo with Pull requests: write") that a bare error code doesn't.
127
+ const suffix = err.detail && err.detail !== errorMessage ? ` — ${err.detail}` : "";
128
+ throw new Error(`Nous API error (${res.status}): ${errorMessage}${suffix}`);
126
129
  }
127
130
 
128
131
  // Some endpoints return empty 204
package/src/server.js CHANGED
@@ -13,22 +13,29 @@
13
13
  * Tools, by group (the authoritative live catalog is `node scripts/list-tools.mjs`):
14
14
  * READ get_context · get_account · verify · query · attention · get_action_items ·
15
15
  * pipeline · pipeline_intelligence · campaign_performance · score ·
16
- * search_notes · list_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
16
+ * search_notes · get_foundation
17
+ * WRITE record · record_signal · save_note · merge_contacts · sync_foundation
18
+ * ACT draft_email · send_linkedin_message
20
19
  * CORRECT retract_observation · delete_note · unmerge_contacts
21
20
  * RUN get_workspace_status · whoami · list_integrations · set_workspace_profile ·
22
- * build_icp_model · train_icp_model · sync_icp · export_icp_model ·
23
- * connect_integration · configure_crm_sync · sync_crm_now · scrape_engagers ·
24
- * get_routing_preferences
21
+ * build_icp_model · train_icp_model · sync_icp ·
22
+ * connect_integration · configure_crm_sync · sync_crm_now · scrape_engagers
25
23
  */
26
24
 
27
25
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
26
  import { z } from "zod";
29
27
  import { get, post, del } from "./client.js";
30
28
 
31
- export const SERVER_VERSION = "0.50.0";
29
+ // Public web-app URL for one-click connect deep links. OAuth providers (Gmail,
30
+ // LinkedIn) need a browser sign-in the agent can't perform headlessly, so instead
31
+ // of a dead-end the agent hands the user a link that lands on the Integrations page
32
+ // with that provider's connect flow pre-opened. Overridable via NOUS_APP_URL.
33
+ const APP_URL = () => (process.env.NOUS_APP_URL || "https://app.opennous.cloud").replace(/\/+$/, "");
34
+ // OAuth/browser-sign-in providers → the slug the Integrations page auto-opens on.
35
+ const OAUTH_CONNECT = { gmail: "gmail", gmail_oauth: "gmail", google: "gmail", "google-mail": "gmail", googlemail: "gmail", linkedin: "linkedin" };
36
+ const connectLink = (slug) => `${APP_URL()}/settings?section=integrations&connect=${slug}`;
37
+
38
+ export const SERVER_VERSION = "0.52.0";
32
39
 
33
40
  // ─── helpers ──────────────────────────────────────────────────────────────────
34
41
 
@@ -69,54 +76,6 @@ const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " "
69
76
  const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
70
77
  const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
71
78
 
72
- // Routing preferences — the text that makes an agent treat Nous as the default
73
- // for GTM (reach for get_context/get_account over raw CRM/HubSpot/Salesforce/
74
- // Gong/Granola). Returned by get_routing_preferences; the agent writes it into
75
- // the user's CLAUDE.md (Claude Code) or the user pastes it into claude.ai org
76
- // preferences. Mirrors the Install page's "short" version.
77
- const ROUTING_PREFERENCES = `# Nous is this workspace's Revenue Context Layer
78
-
79
- Nous is our Revenue Context Layer for GTM: the pre-computed context every agent reads
80
- before it acts. Underneath, a context graph resolves every person, conversation, and
81
- touchpoint across our GTM tool stack into one account record, with each fact's
82
- confidence and freshness, the full interaction timeline, a 0-100 ICP fit score on
83
- every account, plus our own ICP, positioning, and pricing. Agents read engineered
84
- context from Nous rather than raw CRM rows.
85
-
86
- ## Ground GTM work in Nous
87
-
88
- Before any go-to-market task (drafting outreach, prepping a call or meeting,
89
- reviewing an account, qualifying a prospect) call get_context with the person's
90
- email and the matching intent. Use what it returns. Do not answer from generic
91
- knowledge when our own record holds the facts.
92
-
93
- ## Default to Nous, even unprompted
94
-
95
- For any GTM question about a person, company, deal, or our own business, reach for
96
- Nous first even when the user never says "Nous":
97
- - Who is this, every known fact, full history -> get_account
98
- - Context for a specific task -> get_context
99
- - Something happened or you learned a fact -> record
100
- - Activity or a list across many accounts -> query
101
- - What needs attention, what has gone quiet -> attention
102
- - Your action items / what you owe an account -> get_action_items
103
- - A fact looks stale before you act on it -> verify
104
- - Our own rules: ICP, positioning, voice, outreach -> get_foundation
105
- - Our ICP/positioning lives in our own files -> sync_icp (file → graph) / export_icp_model (graph → file)
106
- - You edited an ICP / context / foundation file -> re-sync THIS turn: sync_icp (ICP/context) or sync_foundation (a foundation)
107
- - A brief / note / transcript on a contact -> save_note / search_notes
108
- - What's set up here and what to do next -> get_workspace_status
109
-
110
- Read get_foundation at the start of GTM work for the user's own rules (voice,
111
- outreach, icp, positioning). After every interaction you help with, call record so
112
- the record stays current.
113
-
114
- CRITICAL — edits don't apply until you sync. Editing an ICP / context / foundation
115
- file (context/icp.md, positioning.md, references/voice.md, …) does NOT change the
116
- ICP score, the exclusions, or what any other agent reads until it is synced into
117
- the graph. After ANY such edit you MUST call sync_icp (for ICP/context files) or
118
- sync_foundation (for a foundation) in the SAME turn. Never leave an edited file
119
- unsynced — an unsynced edit is silently inert.`;
120
79
 
121
80
  // ─── factory ──────────────────────────────────────────────────────────────────
122
81
 
@@ -467,14 +426,24 @@ export function createServer() {
467
426
  "kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
468
427
  "email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
469
428
  "learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
470
- "a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
429
+ "a fact ended (they left): {kind:'state',property:'job_title',value:null}. For a piece of INTEL " +
430
+ "about the contact — a preference, an objection, a competitor they mentioned, a general note — " +
431
+ "use property:'intel' with a category: {kind:'state',property:'intel',value:{category:'objection'," +
432
+ "content:'skeptical about market demand',label:'Market demand skepticism'}} (category ∈ " +
433
+ "preference | objection | competitor | buying_signal | budget | timing | authority | general). " +
434
+ "Insights about YOUR OWN business (product/positioning/market/buyer) go to record_insight, not here.",
471
435
  {
472
- focus: z.string().describe("Email address or entity UUID of the person or company"),
436
+ focus: z.string().describe("Email address, LinkedIn URL, domain, or entity UUID a precise identifier, never a bare name"),
473
437
  observations: z.array(z.object({
474
438
  kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
475
439
  property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
476
440
  value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
477
- source: z.string().optional().describe("where this came from (default: agent)"),
441
+ source: z.string().optional().describe("where this came from — provider slug, e.g. 'fireflies', 'gmail' (default: agent)"),
442
+ method: z.string().optional().describe("api | webhook | extraction | inference | user_input (default: api)"),
443
+ observed_at: z.string().optional().describe("ISO timestamp of WHEN this happened/was true. REQUIRED when importing history — otherwise it's stamped now."),
444
+ external_id: z.string().optional().describe("idempotency key, unique per (source). Re-recording the same one is a no-op. One raw item → many observations → give each a distinct id, e.g. '<itemId>:<property>'."),
445
+ source_ref: z.string().optional().describe("pointer to the raw this came from, e.g. 'git://<repo>/raw/fireflies/2026-03-14-9182.md'. Keeps provenance without storing raw in Nous."),
446
+ content_hash: z.string().optional().describe("sha256 of the raw file, so a later fetch can be verified untampered. Set when raw lives in git (not sent here)."),
478
447
  })).describe("One or more observations to record"),
479
448
  },
480
449
  async ({ focus, observations }) => {
@@ -532,6 +501,41 @@ export function createServer() {
532
501
  }
533
502
  );
534
503
 
504
+ // ===========================================================================
505
+ // TOOL: record_insight — POST /v2/insights
506
+ // The mirror of record. record captures facts ABOUT the contact; this captures
507
+ // what a call/email taught us about OUR OWN business — product, positioning,
508
+ // market, buyer. Workspace-level (about us, no focus). Feeds the Insights page
509
+ // and theme clustering. In the plugin model the extraction runs on the agent's
510
+ // own tokens, so this is where those insights land.
511
+ // ===========================================================================
512
+ server.tool(
513
+ "record_insight",
514
+ "Record what a call or email taught us about OUR OWN business — our product, positioning, " +
515
+ "market, or buyer. This is the MIRROR of record: record captures facts about the CONTACT; this " +
516
+ "captures what we learned about US. Workspace-level — there is no focus (it's not about one " +
517
+ "account). category is one of product | positioning | market | buyer. Include the verbatim quote " +
518
+ "and who said it whenever you can. These feed the Insights page and its theme clustering. Send " +
519
+ "all insights from one source in a single call. Do NOT put contact facts here — use record.",
520
+ {
521
+ insights: z.array(z.object({
522
+ category: z.enum(["product", "positioning", "market", "buyer"]).describe("which lens this is about"),
523
+ content: z.string().describe("the insight in one clear sentence, stated from OUR perspective"),
524
+ quote: z.string().optional().describe("the verbatim thing they said that supports it"),
525
+ speaker: z.string().optional().describe("who said it — a name or a role"),
526
+ })).describe("one or more insights extracted from the same call/email"),
527
+ source_label: z.string().optional().describe("where these came from, e.g. 'Acme discovery call'"),
528
+ occurred_at: z.string().optional().describe("ISO timestamp of the call/email"),
529
+ },
530
+ async ({ insights, source_label, occurred_at }) => {
531
+ const r = await post("/v2/insights", { insights, source_label, occurred_at });
532
+ return { content: [{ type: "text", text:
533
+ `Recorded ${r.written} insight${r.written !== 1 ? "s" : ""}` +
534
+ (r.submitted && r.written < r.submitted ? ` (${r.submitted - r.written} were duplicates)` : "") +
535
+ ` into the workspace's product/positioning/market/buyer docs.` }] };
536
+ }
537
+ );
538
+
535
539
  // ===========================================================================
536
540
  // TOOL: query — POST /v2/query
537
541
  // Retrieve a corpus of activity across many people. You do the analysis.
@@ -626,12 +630,21 @@ export function createServer() {
626
630
  const names = (r.candidates || []).map(c => c.name || c.entity_id).join(", ");
627
631
  return `ambiguous — several people match${names ? `: ${names}` : ""}. Score by email or LinkedIn URL instead.`;
628
632
  }
629
- return "not in the graph yet — enrich this account first (signals/firmographics), then score.";
633
+ return "not in the graph yet — pass the lead's `attributes` (title, company, keywords) to score it inline, or enrich the account first.";
634
+ }
635
+ const layers = r.layered && r.layered.layers ? r.layered.layers : {};
636
+ const layerLine = Object.keys(layers).length ? `\n layers: ${Object.entries(layers).map(([k, v]) => `${k} ${v}`).join(" · ")}` : "";
637
+ const work = (r.layered && r.layered.missing && r.layered.missing.length)
638
+ ? `\n unknown: ${r.layered.missing.join(", ")} — ${r.layered.play || "enrich to resolve"}` : "";
639
+ if (!r.scored) {
640
+ // 'partial' — Fit/Pain resolved live (not staked). Show what we know + the worklist.
641
+ if (Object.keys(layers).length) return `partial fit${layerLine}${work}`;
642
+ return `known, but awaiting enrichment — no scoreable claims yet (${r.entity_id}).`;
630
643
  }
631
- if (!r.scored) return `known, but awaiting enrichment — no scoreable claims yet (${r.entity_id}).`;
632
644
  const tier = (r.icp.tier || "").replace(/_/g, " ") || "untiered";
633
- return `ICP ${r.icp.score}/100 (${tier})${r.icp.fit ? " ✓fit" : ""} · intent ${r.intent.score}/100 ${r.intent.band}` +
634
- (r.icp.reason ? `\n ${r.icp.reason}` : "");
645
+ const inline = r.source === "inline" ? " · scored inline (not in the graph)" : "";
646
+ return `ICP ${r.icp.score}/100 (${tier})${r.icp.fit ? " ✓fit" : ""} · intent ${r.intent.score}/100 ${r.intent.band}${inline}` +
647
+ layerLine + work + (r.icp.reason ? `\n ${r.icp.reason}` : "");
635
648
  };
636
649
  server.tool(
637
650
  "score",
@@ -639,24 +652,40 @@ export function createServer() {
639
652
  "every other agent reads the same number. This is for scoring a list the user built ELSEWHERE (a " +
640
653
  "Google Sheet, a Clay column, a CRM export): the list stays where it is, Nous returns the score. " +
641
654
  "Give an email, domain, LinkedIn URL, or entity UUID (or up to 100 at once via `identifiers`). " +
642
- "Returns ICP fit 0-100 + tier (tier_1/2/3/not_icp, which drives the play) and decaying intent " +
643
- "0-100 + band. It reads the live score (staking one on demand for a known-but-unscored account); " +
644
- "the score keeps evolving on its own afterwards. If a row isn't in the graph yet it comes back " +
645
- "`unknown_identifier` enrich that account first (signal-scan / a lead-builder), then score.",
655
+ "Returns ICP fit 0-100 + tier (tier_1/2/3/not_icp, which drives the play), decaying intent " +
656
+ "0-100 + band, AND the LAYERED read: layers {fit,pain,intent,ability} with a `missing` worklist " +
657
+ "(what's still unknown unknown is never 0) and a suggested `play`. It reads the live score " +
658
+ "(staking one on demand for a known-but-unscored account); the score keeps evolving afterwards. " +
659
+ "COLD LEAD not in Nous? Pass `attributes` (title, company, keywords/headline) and it's scored " +
660
+ "INLINE against the model — Fit/Pain resolve from what you have, nothing is written. For a whole " +
661
+ "EXTERNAL list, pass `leads` (up to 1000 attribute objects) — all scored inline in one call, model " +
662
+ "loaded once, nothing written — so you can triage a big list before deciding what to bring in. Only " +
663
+ "a bare identifier with no attributes and no graph record comes back `unknown_identifier`.",
646
664
  {
647
665
  identifier: z.string().optional().describe("One lead — an email, domain, LinkedIn URL, or entity UUID."),
648
- identifiers: z.array(z.string()).optional().describe("A batch of leads (max 100 per call; loop for a larger list)."),
666
+ identifiers: z.array(z.string()).optional().describe("A batch of graph leads (max 100 per call; loop for a larger list)."),
667
+ attributes: z.record(z.any()).optional().describe("For a COLD lead NOT in Nous: its attributes from LinkedIn/a list — { job_title, seniority, company_type, industry, employee_count, keywords or headline, location }. Scored inline against the model; nothing is written. Use when the identifier isn't in the graph yet, or pass on its own with no identifier."),
668
+ leads: z.array(z.record(z.any())).optional().describe("An EXTERNAL cold list scored inline in one call (up to 1000): each item is an attributes object { job_title, company_type, keywords|headline, ... }, optionally with a `ref` echoed back. Nothing is written to the graph. Use to triage a big list before deciding which accounts to bring in."),
649
669
  intent: z.string().optional().describe("Optional hint about why you're scoring (recorded, does not change the score)."),
650
670
  },
651
- async ({ identifier, identifiers, intent }) => {
671
+ async ({ identifier, identifiers, intent, attributes, leads }) => {
672
+ if (Array.isArray(leads) && leads.length) {
673
+ const r = await post("/v2/score", { leads });
674
+ const rows = r.results || [];
675
+ const scored = rows.filter(x => x.scored);
676
+ const byTier = {};
677
+ for (const x of scored) { const t = (x.icp?.tier || "untiered").replace(/_/g, " "); byTier[t] = (byTier[t] || 0) + 1; }
678
+ const dist = Object.entries(byTier).sort((a, b) => b[1] - a[1]).map(([t, n]) => `${t} ${n}`).join(" · ") || "none";
679
+ return { content: [{ type: "text", text: `Scored ${scored.length}/${rows.length} inline (nothing written). Tiers: ${dist}` }] };
680
+ }
652
681
  if (Array.isArray(identifiers) && identifiers.length) {
653
682
  const r = await post("/v2/score", { identifiers, intent });
654
683
  const lines = (r.results || []).map(x => ` ${x.identifier} — ${scoreOne(x)}`);
655
684
  const scored = (r.results || []).filter(x => x.scored).length;
656
685
  return { content: [{ type: "text", text: `Scored ${scored}/${(r.results || []).length}:\n${lines.join("\n")}` }] };
657
686
  }
658
- const r = await post("/v2/score", { identifier, intent });
659
- return { content: [{ type: "text", text: `${identifier} — ${scoreOne(r)}` }] };
687
+ const r = await post("/v2/score", { identifier, intent, attributes });
688
+ return { content: [{ type: "text", text: `${identifier || "cold lead"} — ${scoreOne(r)}` }] };
660
689
  }
661
690
  );
662
691
 
@@ -695,6 +724,78 @@ export function createServer() {
695
724
  }
696
725
  );
697
726
 
727
+ // ===========================================================================
728
+ // TOOL: list_unresolved — GET /v2/unresolved
729
+ // Inbound events (meeting transcripts) received but tied to nobody.
730
+ // ===========================================================================
731
+ server.tool(
732
+ "list_unresolved",
733
+ "Inbound meetings that were received but couldn't be matched to any contact — the transcript came " +
734
+ "in (a call was recorded) but the attendee couldn't be identified (joined without an email we have, " +
735
+ "or the contact didn't exist yet). Each item shows the meeting title, date, the names/emails we saw " +
736
+ "on the call, and a summary. Use this to find calls that never landed on anyone's timeline, then call " +
737
+ "resolve_unresolved with the id + who it belongs to — that re-runs the full pipeline (notes + extracted " +
738
+ "facts) against the right person. Answers 'what meetings didn't get logged / matched?'.",
739
+ {
740
+ limit: z.number().min(1).max(200).optional().describe("Max items (default 50)"),
741
+ },
742
+ async ({ limit }) => {
743
+ const r = await get("/v2/unresolved", limit ? { limit } : {});
744
+ if (!r.unresolved?.length) {
745
+ return { content: [{ type: "text", text: "No unresolved meetings — everything received matched a contact." }] };
746
+ }
747
+ const lines = r.unresolved.map(it => {
748
+ const when = it.occurred_at ? `${fmtWhen(it.occurred_at)} — ` : "";
749
+ const who = [
750
+ it.detected_names?.length ? `names: ${it.detected_names.join(", ")}` : null,
751
+ it.detected_emails?.length ? `emails: ${it.detected_emails.join(", ")}` : null,
752
+ ].filter(Boolean).join("; ");
753
+ const snip = it.summary ? `\n ${String(it.summary).replace(/\s+/g, " ").slice(0, 160)}` : "";
754
+ return ` [${it.id}] ${when}${it.title || "Untitled"}${who ? `\n (${who})` : ""}${snip}`;
755
+ });
756
+ return { content: [{ type: "text", text:
757
+ `Unresolved meetings (${r.unresolved.length}) — resolve each with resolve_unresolved(id, focus):\n${lines.join("\n")}` }] };
758
+ }
759
+ );
760
+
761
+ // ===========================================================================
762
+ // TOOL: resolve_unresolved — POST /v2/unresolved/:id/resolve
763
+ // Attach an unresolved meeting to a contact and re-run the full ingest pipeline.
764
+ // ===========================================================================
765
+ server.tool(
766
+ "resolve_unresolved",
767
+ "Attach an unresolved meeting (from list_unresolved) to the contact it belongs to. Pass the item id and " +
768
+ "`focus` (the person's email, entity UUID, or name). This links the meeting's detected email to that " +
769
+ "contact for next time, then re-runs the NORMAL pipeline — the call lands on their timeline with notes " +
770
+ "and the usual claim/insight extraction, exactly as a clean webhook would. Use after list_unresolved once " +
771
+ "you've decided who a call belongs to. If `focus` is a name that matches several people, you'll get an error " +
772
+ "with candidates — pass the specific entity UUID.",
773
+ {
774
+ id: z.string().describe("The unresolved item id from list_unresolved"),
775
+ focus: z.string().describe("Who the meeting belongs to — an email, entity UUID, or full name"),
776
+ },
777
+ async ({ id, focus }) => {
778
+ const r = await post(`/v2/unresolved/${encodeURIComponent(id)}/resolve`, { focus });
779
+ if (r?.ambiguous) {
780
+ const cands = (r.candidates || []).map(c =>
781
+ ` ${c.name || c.entity_id}${c.company ? ` (${c.company})` : ""} — ${c.entity_id}`).join("\n");
782
+ return { content: [{ type: "text", text:
783
+ `"${focus}" matches several people — re-run resolve_unresolved with the exact entity UUID:\n${cands}` }] };
784
+ }
785
+ if (r?.error === "contact_not_found") {
786
+ return { content: [{ type: "text", text: `No contact matched "${focus}". Try an email or the exact entity UUID.` }] };
787
+ }
788
+ if (r?.already) {
789
+ return { content: [{ type: "text", text: "Already resolved." }] };
790
+ }
791
+ if (r?.resolved) {
792
+ return { content: [{ type: "text", text: `Resolved — the meeting is now on ${focus}'s timeline with notes and extracted facts (contact ${r.entity_id}).` }] };
793
+ }
794
+ return { content: [{ type: "text", text:
795
+ `Linked to contact ${r.entity_id ?? focus}, but the re-ingest logged nothing (it may have already been recorded, or the transcript is no longer fetchable). Worker: ${JSON.stringify(r.worker || {})}` }] };
796
+ }
797
+ );
798
+
698
799
  // ===========================================================================
699
800
  // TOOL: campaign_performance — GET /v2/campaigns/performance
700
801
  // The aggregate outbound feedback loop: which campaign/variant earns positive
@@ -899,13 +1000,13 @@ export function createServer() {
899
1000
 
900
1001
  // ===========================================================================
901
1002
  // TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
902
- // Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
903
- // icp, positioning. Read the relevant one BEFORE acting; push file edits back so
1003
+ // The one foundation Nous keeps is the ICP: a versioned rule-doc that GOVERNS how
1004
+ // accounts are scored and qualified. Read it BEFORE scoring; push file edits back so
904
1005
  // every agent obeys the same rules. GET/POST /v2/foundations.
905
1006
  // ===========================================================================
906
1007
  const getFoundationSchema = {
907
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).optional()
908
- .describe("Which policy to read. Omit to list all four."),
1008
+ kind: z.enum(["icp"]).optional()
1009
+ .describe("Which policy to read. Nous keeps one foundation: the ICP."),
909
1010
  };
910
1011
  const getFoundationHandler = async ({ kind }) => {
911
1012
  const r = await get("/v2/foundations", kind ? { kind } : undefined);
@@ -923,14 +1024,12 @@ export function createServer() {
923
1024
  "The user's foundations (read one with get_foundation(kind)):\n" + lines.join("\n") }] };
924
1025
  };
925
1026
  server.tool("get_foundation",
926
- "Read a FOUNDATION — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
927
- "These are RULES TO OBEY, not facts. Read the relevant foundation BEFORE you act: before writing outreach " +
928
- "read 'voice' and 'outreach'; before scoring or qualifying read 'icp'; for messaging read 'positioning'. " +
929
- "Omit kind to list all four.",
1027
+ "Read the ICP FOUNDATION — the user's rules for who counts as a fit. This is RULES TO OBEY, not facts. " +
1028
+ "Read it BEFORE you score or qualify an account.",
930
1029
  getFoundationSchema, getFoundationHandler);
931
1030
 
932
1031
  const syncFoundationSchema = {
933
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which foundation to update."),
1032
+ kind: z.enum(["icp"]).optional().describe("The foundation to update. Nous holds only the ICP; positioning, voice and messaging are not foundations Nous tracks."),
934
1033
  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."),
935
1034
  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."),
936
1035
  };
@@ -941,7 +1040,7 @@ export function createServer() {
941
1040
  };
942
1041
  server.tool("sync_foundation",
943
1042
  "Push a foundation's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
944
- "whenever you edit a policy file in the repo (e.g. references/voice.md, outreach rules), passing the " +
1043
+ "whenever you edit the ICP file in the repo (e.g. context/icp.md), passing the " +
945
1044
  "file's new content and its path, so Nous mirrors it and every other agent obeys the same rules. An " +
946
1045
  "edited foundation file that isn't synced is silently inert — other agents keep reading the old rules. " +
947
1046
  "MIRROR, DO NOT REWRITE: when the user already has a foundation file, sync it AS-IS. Their file is the " +
@@ -952,38 +1051,6 @@ export function createServer() {
952
1051
  "(For the ICP/context files specifically, sync_icp is the sync — use that one.)",
953
1052
  syncFoundationSchema, syncFoundationHandler);
954
1053
 
955
- // ===========================================================================
956
- // TOOL: get_insights — what Nous LEARNED about us from calls (the mirror of
957
- // foundations). Insights are auto-extracted from call transcripts into four docs:
958
- // product, positioning, market, buyer. READ-ONLY over MCP — the extractor
959
- // authors them, not agents. GET /v2/insights[?category=].
960
- // ===========================================================================
961
- const getInsightsSchema = {
962
- category: z.enum(["product", "positioning", "market", "buyer"]).optional()
963
- .describe("Which insight doc to read. Omit to list all four."),
964
- };
965
- const getInsightsHandler = async ({ category }) => {
966
- const r = await get("/v2/insights", category ? { category } : undefined);
967
- const docs = r.insights || [];
968
- if (!docs.length) return { content: [{ type: "text", text:
969
- "No insights captured yet. They fill automatically from call transcripts (product, positioning, market, buyer)." }] };
970
- if (category) {
971
- const d = docs[0];
972
- return { content: [{ type: "text", text:
973
- `# ${d.title} insights (v${d.version})\n\n${d.body_md || "(empty)"}` }] };
974
- }
975
- const lines = docs.map(d => ` ${d.category.padEnd(12)} ${d.title} (v${d.version})`);
976
- return { content: [{ type: "text", text:
977
- "What Nous learned about us from calls (read one with get_insights(category)):\n" + lines.join("\n") }] };
978
- };
979
- server.tool("get_insights",
980
- "Read INSIGHTS — what Nous learned about US from call transcripts, the mirror of the foundations/foundations " +
981
- "the user authors. Four docs: product (what to build), positioning (how to message), market (segments, " +
982
- "wedges, channels), buyer (ICP, the pain that drives the purchase). These accumulate automatically after " +
983
- "every call. Read them when working on product direction, messaging, GTM strategy, or targeting. Omit " +
984
- "category to list all four.",
985
- getInsightsSchema, getInsightsHandler);
986
-
987
1054
  // ===========================================================================
988
1055
  // TOOL: save_note — POST /v2/notes
989
1056
  // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
@@ -1017,35 +1084,11 @@ export function createServer() {
1017
1084
  },
1018
1085
  );
1019
1086
 
1020
- // ===========================================================================
1021
- // TOOL: propose_vault_file — POST /v2/personal/propose
1022
- // Propose a markdown file into the member's PERSONAL vault. It lands in their
1023
- // inbox as a proposal the member approves it before it is filed into a folder
1024
- // or synced to their Git. This is how an agent contributes to a member's own
1025
- // notes (thoughts, decisions, briefs, content) without writing anything without
1026
- // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
1027
- // record) — this is the member's private vault, not an account.
1028
- // ===========================================================================
1029
- server.tool(
1030
- "propose_vault_file",
1031
- "Propose a markdown file into the member's personal vault (their private notes, not an " +
1032
- "account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
1033
- "into a folder and synced to their Git; nothing is written without their approval. Use this to " +
1034
- "draft a thought, a decision record, a brief, or content for the member to review and keep. " +
1035
- "Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
1036
- "GTM profile (use sync_icp).",
1037
- {
1038
- folder: z.enum(["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"])
1039
- .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."),
1040
- 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'."),
1041
- content: z.string().describe("The full markdown content of the file."),
1042
- subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
1043
- },
1044
- async ({ folder, name, content, subfolder }) => {
1045
- await post("/v2/personal/propose", { folder, name, content, subfolder });
1046
- return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
1047
- },
1048
- );
1087
+ // (Retired 2026-08-22) propose_vault_file — the agent no longer authors files into a
1088
+ // member's personal vault. The vault surface is now the Sync page only (connect a repo);
1089
+ // Nous auto-pushes account/people EVIDENCE to `nous/` in that repo, and agent-authored
1090
+ // knowledge lives Nous-side (save_note on the account record), not as approval-gated files.
1091
+ // The inbox approval flow was retired with it. See AIOS decisions/log.md 2026-08-22.
1049
1092
 
1050
1093
  // ===========================================================================
1051
1094
  // TOOL: search_notes — POST /v2/notes/search
@@ -1097,8 +1140,9 @@ export function createServer() {
1097
1140
  "carries its own why/how). Nous is operated by you, the agent — call this at the START of a session " +
1098
1141
  "and walk the user top-down through the steps it returns; the server sequences them by current " +
1099
1142
  "state, so trust that order. Two constraints when acting on them: (1) Gmail (Google OAuth) and " +
1100
- "LinkedIn (no public API Nous uses Unipile) CANNOT be connected by you point the user to the " +
1101
- "Integrations page; key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect via " +
1143
+ "LinkedIn need a browser sign-in you can't perform but you're not stuck: call connect_integration " +
1144
+ "with just the provider ('gmail' or 'linkedin') to get a one-click connect link, and send the user " +
1145
+ "that link. Key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you connect directly via " +
1102
1146
  "connect_integration, and CSV import is a user action in the app. (2) Respect the plan — never push " +
1103
1147
  "a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
1104
1148
  "whole list.",
@@ -1138,7 +1182,7 @@ export function createServer() {
1138
1182
  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)` : ""}`);
1139
1183
  if (setup.icp_sync) {
1140
1184
  const sy = setup.icp_sync;
1141
- 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" : ""}`);
1185
+ lines.push(` ⟳ ICP synced from ${sy.synced_from} (${relAge(sy.synced_at)})${sy.model_changed ? " · model has CHANGED since" : ""}`);
1142
1186
  }
1143
1187
  const ints = setup.integrations?.connected ?? [];
1144
1188
  lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
@@ -1154,6 +1198,8 @@ export function createServer() {
1154
1198
  lines.push("");
1155
1199
  lines.push("RECOMMENDED CHANNELS (connect these first):");
1156
1200
  lines.push(` ${mark(rec.email)} Email / Gmail ${mark(rec.linkedin)} LinkedIn ${mark(rec.meeting_notetaker)} Meeting note-taker`);
1201
+ if (!rec.email) lines.push(` → send the user this one-click Gmail connect link: ${connectLink("gmail")}`);
1202
+ if (!rec.linkedin) lines.push(` → send the user this one-click LinkedIn connect link: ${connectLink("linkedin")}`);
1157
1203
  lines.push(` Records imported: ${setup.records?.count ?? 0}`);
1158
1204
 
1159
1205
  if (s.next_steps?.length) {
@@ -1272,6 +1318,19 @@ export function createServer() {
1272
1318
  );
1273
1319
 
1274
1320
  // ===========================================================================
1321
+ // A closed deal as the agent may supply it. A bare domain is the original shape and still
1322
+ // works; the object form carries the money and the date, which the forecast needs. Widened
1323
+ // rather than replaced so nothing that already calls this breaks.
1324
+ const CLOSED_DEAL = z.union([
1325
+ z.string(),
1326
+ z.object({
1327
+ domain: z.string().describe("Company domain, e.g. 'acme.com' — no scheme."),
1328
+ amount: z.number().optional().describe("Deal value in major units (48000, not 4800000)."),
1329
+ currency: z.string().optional().describe("ISO code or symbol. Defaults to the workspace currency."),
1330
+ closed_at: z.string().optional().describe("When it actually closed, ISO date. Drives every cycle-time measurement, so pass the real date rather than today's."),
1331
+ }),
1332
+ ]);
1333
+
1275
1334
  // TOOL: train_icp_model — POST /v2/workspace/closed-deals
1276
1335
  // Build the ICP model from REAL outcomes via contrastive lift (won vs lost).
1277
1336
  // ===========================================================================
@@ -1282,11 +1341,18 @@ export function createServer() {
1282
1341
  "have there, and runs contrastive lift (what's true of winners but not losers) to discover the " +
1283
1342
  "signals that actually predict revenue — then re-scores open accounts. This is the strongest way " +
1284
1343
  "to build the foundation: a model trained on who actually bought beats one inferred from a " +
1285
- "description. Ask the user for a handful of each (even 3-5 won + 3-5 lost helps). Domains only " +
1286
- "(e.g. 'acme.com'), no scheme.",
1344
+ "description. Ask the user for a handful of each (even 3-5 won + 3-5 lost helps). " +
1345
+ "ALWAYS ASK FOR THE DEAL AMOUNT AND THE DATE IT CLOSED where the user knows them, and pass " +
1346
+ "them: they are what the revenue forecast is built from, and a deal imported without them " +
1347
+ "teaches the model who buys but not what a deal is worth or how long one takes. A bare domain " +
1348
+ "string still works when that is all the user has.",
1287
1349
  {
1288
- won: z.array(z.string()).optional().describe("Closed-won customer domains, e.g. ['acme.com','globex.com']."),
1289
- lost: z.array(z.string()).optional().describe("Closed-lost domains, e.g. ['tinyco.io']."),
1350
+ won: z.array(CLOSED_DEAL).optional().describe(
1351
+ "Closed-won deals. Either a bare domain ('acme.com') or, much better, " +
1352
+ "{ domain, amount, currency, closed_at } — e.g. " +
1353
+ "{ domain: 'acme.com', amount: 48000, currency: 'USD', closed_at: '2026-03-14' }."),
1354
+ lost: z.array(CLOSED_DEAL).optional().describe(
1355
+ "Closed-lost deals, same shape as `won`. The amount is what the deal WOULD have been worth."),
1290
1356
  },
1291
1357
  async ({ won, lost }) => {
1292
1358
  try {
@@ -1317,8 +1383,8 @@ export function createServer() {
1317
1383
  // The file→Nous half of the ICP symbiosis. In Claude Code the user often
1318
1384
  // already keeps their ICP/positioning as markdown (context/icp.md, etc.). Don't
1319
1385
  // make them re-author it in Nous — READ those files and sync them here. Nous
1320
- // mirrors each section and remembers the file path so export_icp_model can write
1321
- // the learned model back into the same file. Their file stays the source of
1386
+ // mirrors each section and remembers the file path so the learned model can be
1387
+ // written back into the same file. Their file stays the source of
1322
1388
  // truth for the prose; Nous owns the learned scoring half.
1323
1389
  // ===========================================================================
1324
1390
  server.tool(
@@ -1333,7 +1399,7 @@ export function createServer() {
1333
1399
  "Market, messaging.md -> Notes) — one entry per file, do NOT dump several files' content into ICP. " +
1334
1400
  "If one file holds several sections under headers, split it by header into multiple entries. " +
1335
1401
  "Nous keeps a served copy of the prose and rebuilds the ICP scoring model from it; " +
1336
- "the recorded source_path is what export_icp_model writes the learned model back into. " +
1402
+ "the recorded source_path is where the learned model is written back. " +
1337
1403
  "INCLUDE EXCLUSIONS: if the ICP names who they will NOT work with (e.g. 'not cold-calling " +
1338
1404
  "agencies', 'no pure branding/messaging shops'), keep that text IN the ICP section — Nous turns " +
1339
1405
  "each stated exclusion into a hard disqualifier that caps those accounts below Not-ICP, even when " +
@@ -1346,7 +1412,7 @@ export function createServer() {
1346
1412
  "MANDATORY RE-SYNC: whenever you (or the user) edit the ICP/context file — add or change an exclusion, " +
1347
1413
  "reword the ICP, retarget — you MUST call sync_icp again in the SAME turn. The edit does NOT change the " +
1348
1414
  "ICP score, the exclusions, or the scoring model until you do; an unsynced file edit is silently inert. " +
1349
- "The ICP section's source_path matters most (it's the write-back target for export_icp_model).",
1415
+ "The ICP section's source_path matters most (it's the write-back target for the learned model).",
1350
1416
  {
1351
1417
  sections: z.array(z.object({
1352
1418
  section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
@@ -1374,11 +1440,11 @@ export function createServer() {
1374
1440
  const sig = r.signals ?? [];
1375
1441
  if (r.model_status === "created" && sig.length) {
1376
1442
  lines.push("", `Built the ICP scoring model — ${sig.length} signal${sig.length === 1 ? "" : "s"}.`);
1377
- lines.push("Next: if the user can name a few closed-won + closed-lost domains, call train_icp_model to sharpen it on real outcomes, then call export_icp_model to write the learned model back into their ICP file.");
1443
+ lines.push("Next: if the user can name a few closed-won + closed-lost domains, call train_icp_model to sharpen it on real outcomes.");
1378
1444
  } else if (r.model_status === "no_icp_memory") {
1379
1445
  lines.push("", "Synced, but there wasn't enough ICP content to build a scoring model — make sure the ICP section has real content.");
1380
1446
  } else {
1381
- lines.push("", "Context synced. Call export_icp_model when you want to write the learned model back into their ICP file.");
1447
+ lines.push("", "Context synced.");
1382
1448
  }
1383
1449
  return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1384
1450
  } catch (e) {
@@ -1393,49 +1459,6 @@ export function createServer() {
1393
1459
  }
1394
1460
  );
1395
1461
 
1396
- // ===========================================================================
1397
- // TOOL: export_icp_model — GET /v2/workspace/icp/model
1398
- // The Nous→file half of the ICP symbiosis. Nous learns which signals actually
1399
- // predict a win (lift + calibration) from real outcomes; this returns that
1400
- // learned model as a ready-to-write fenced block, which the agent writes back
1401
- // into the user's own ICP file with its native editor. Server renders the
1402
- // block so the format is controlled centrally — the agent just persists it.
1403
- // ===========================================================================
1404
- server.tool(
1405
- "export_icp_model",
1406
- "Get the LEARNED ICP scoring model (which signals predict a win, their weight, lift, and the " +
1407
- "calibration gap) as a ready-to-write markdown block, and write it back into the user's own ICP " +
1408
- "file. This is the payoff of the symbiosis: their file keeps the words, Nous keeps the model, and " +
1409
- "this writes the model under their words. CLAUDE CODE flow: call this after sync_icp or after " +
1410
- "train_icp_model, then with your file tools open `target_path`, and if the file already has a " +
1411
- "block between '<!-- nous:icp start -->' and '<!-- nous:icp end -->' REPLACE that whole block with " +
1412
- "the returned `block`; if not, append the returned `block` (e.g. replacing a '## [To refine]' " +
1413
- "placeholder). Never edit inside the markers by hand — this tool regenerates them. Everything " +
1414
- "OUTSIDE the markers is the user's; never touch it.",
1415
- {},
1416
- async () => {
1417
- const r = await get("/v2/workspace/icp/model");
1418
- if (!r.has_model) {
1419
- return { content: [{ type: "text", text:
1420
- "No ICP scoring model yet. Sync the user's ICP file with sync_icp first (or build one with " +
1421
- "build_icp_model / train_icp_model), then call this to write it back." }] };
1422
- }
1423
- // Lift can only be learned from WINS, so a win-less cohort (losses only) is
1424
- // still seed estimates — don't claim it's "trained on real closed deals".
1425
- const wonCount = r.calibration?.won ?? 0;
1426
- const lostCount = r.calibration?.lost ?? 0;
1427
- const note = wonCount > 0
1428
- ? "This model is trained on real closed deals (lift + calibration shown)."
1429
- : lostCount > 0
1430
- ? `Still seed estimates — ${lostCount} closed-lost recorded but no closed-won yet to learn lift from. Add closed-won with train_icp_model to sharpen it.`
1431
- : "This model is seeded from the ICP only — add closed deals with train_icp_model to sharpen it.";
1432
- return { content: [{ type: "text", text:
1433
- `Write the block below into ${r.target_path} with your file editor — replace any existing block ` +
1434
- `between the nous:icp markers, or append it if there's none (create the file/section if absent). ` +
1435
- `Leave everything outside the markers untouched. ${note}\n\n${r.block}` }] };
1436
- }
1437
- );
1438
-
1439
1462
  // ===========================================================================
1440
1463
  // TOOL: connect_integration — POST /v2/workspace/integrations
1441
1464
  // The agent connects a KEY-BASED integration for the user (no clicking through
@@ -1448,21 +1471,34 @@ export function createServer() {
1448
1471
  "authenticates with an API key or token (e.g. Apollo, Prospeo, Instantly, HubSpot private-app " +
1449
1472
  "token, Pipedrive, Attio, Smartlead, HeyReach). Ask the user for the provider's API key, then " +
1450
1473
  "call this; it verifies the credentials before saving. Providers that use a browser sign-in " +
1451
- "(OAuth, e.g. Gmail) can't be connected this way — for those, point the user to the Integrations " +
1452
- "page. After connecting an enrichment provider, the account record starts filling in.",
1474
+ "OAuth providers (Gmail, LinkedIn) can't be keyed in — for those, call this tool with just the " +
1475
+ "provider and it returns a one-click connect link to send the user; they finish sign-in in the " +
1476
+ "browser. After connecting an enrichment provider, the account record starts filling in.",
1453
1477
  {
1454
- provider: z.string().describe("Provider name, lowercase — e.g. 'apollo', 'prospeo', 'instantly', 'hubspot', 'pipedrive', 'attio'."),
1455
- credentials: z.record(z.string()).describe("The provider's credentials as key/value, e.g. { api_key: '...' } or { access_token: '...' }."),
1478
+ provider: z.string().describe("Provider name, lowercase — e.g. 'apollo', 'prospeo', 'instantly', 'hubspot', 'pipedrive', 'attio', or an OAuth provider 'gmail' / 'linkedin'."),
1479
+ credentials: z.record(z.string()).optional().describe("The provider's credentials as key/value, e.g. { api_key: '...' } or { access_token: '...' }. Not needed for OAuth providers (gmail, linkedin) — those return a one-click connect link instead."),
1456
1480
  name: z.string().optional().describe("Optional label for the connection."),
1457
1481
  },
1458
1482
  async ({ provider, credentials, name }) => {
1483
+ // OAuth providers can't be wired up with a key. Instead of a dead-end, hand the
1484
+ // user a one-click link that opens the Integrations page with the connect flow
1485
+ // pre-opened, so a terminal-first user can finish sign-in in their browser.
1486
+ const oauthSlug = OAUTH_CONNECT[String(provider || "").toLowerCase()];
1487
+ if (oauthSlug) {
1488
+ return { content: [{ type: "text", text: `${provider} connects with a browser sign-in, so it can't be wired up with a key. Send the user this one-click link to connect it — they finish the sign-in in their browser and it's live:\n${connectLink(oauthSlug)}` }] };
1489
+ }
1490
+ if (!credentials || !Object.keys(credentials).length) {
1491
+ return { content: [{ type: "text", text: `To connect ${provider} I need its API key. Ask the user for it, then call this tool again with credentials.` }] };
1492
+ }
1459
1493
  try {
1460
1494
  const r = await post("/v2/workspace/integrations", { provider, credentials, name });
1461
1495
  return { content: [{ type: "text", text: `Connected ${r.connection?.provider ?? provider}.${r.message ? ` ${r.message}` : ""}` }] };
1462
1496
  } catch (e) {
1463
1497
  const msg = String(e?.message ?? e);
1464
1498
  if (msg.includes("oauth_provider")) {
1465
- return { content: [{ type: "text", text: `${provider} uses a browser sign-in, so it can't be connected with a key. Tell the user to connect it on the Integrations page.` }] };
1499
+ const slug = OAUTH_CONNECT[String(provider || "").toLowerCase()] || "";
1500
+ const link = slug ? `\n${connectLink(slug)}` : "";
1501
+ return { content: [{ type: "text", text: `${provider} uses a browser sign-in, so it can't be connected with a key. Send the user this one-click connect link:${link || " open the Integrations page in the app."}` }] };
1466
1502
  }
1467
1503
  if (msg.includes("invalid_credentials")) {
1468
1504
  return { content: [{ type: "text", text: `Those credentials didn't verify for ${provider}. Ask the user to double-check the key and try again.` }] };
@@ -1595,8 +1631,8 @@ export function createServer() {
1595
1631
 
1596
1632
  // ===========================================================================
1597
1633
  // TOOL: send_linkedin_message — the ACT layer. An OUTWARD ACTION: it goes out
1598
- // through Unipile immediately, it is not a draft. The agent should read the
1599
- // 'voice'/'outreach' foundations first and `record` the touch after. Derives the
1634
+ // through Unipile immediately, it is not a draft. The agent should `record` the
1635
+ // touch after. Derives the
1600
1636
  // workspace from the API key and runs on the workspace's connected LinkedIn
1601
1637
  // account. POST /api/linkedin/send-message (verifyAuthEither).
1602
1638
  // ===========================================================================
@@ -1607,8 +1643,7 @@ export function createServer() {
1607
1643
  "only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
1608
1644
  "profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
1609
1645
  "existing thread. It returns the thread's `chat_id` — keep it so the next reply stays in the same " +
1610
- "thread. Best practice: read the 'voice' and 'outreach' foundations first so the message obeys the " +
1611
- "user's rules, and `record` the touch afterward so the graph stays current. Requires a connected " +
1646
+ "thread. Best practice: `record` the touch afterward so the graph stays current. Requires a connected " +
1612
1647
  "LinkedIn account (Integrations); it says so if none is connected.",
1613
1648
  {
1614
1649
  text: z.string().min(1).describe("The message body to send."),
@@ -1639,6 +1674,43 @@ export function createServer() {
1639
1674
  }
1640
1675
  );
1641
1676
 
1677
+ server.tool(
1678
+ "draft_email",
1679
+ "DRAFT a follow-up email for the operator to review and SEND themselves — you draft, they send. " +
1680
+ "This does NOT send: it puts a card in front of the operator with To / Subject / Body and a Send " +
1681
+ "button; the email goes out from THEIR OWN Gmail, in their name, only when they click send. Use it " +
1682
+ "whenever the ask is to email, follow up by email, send a recap, or reply by email. Identify the " +
1683
+ "recipient with `focus` (their email, LinkedIn URL, entity id, or name); the recipient address is " +
1684
+ "read off the record unless you pass `to`. Write a real `subject` and `body` in the operator's " +
1685
+ "voice — real paragraphs, no placeholders or [brackets] — and give the `rationale`, the one line " +
1686
+ "they judge the draft against. For a LinkedIn-only contact with no email, use send_linkedin_message.",
1687
+ {
1688
+ focus: z.string().describe("The recipient — email, LinkedIn URL, entity UUID, or name."),
1689
+ subject: z.string().describe("The email subject line."),
1690
+ body: z.string().min(1).describe("The email body, in the operator's voice. Real paragraphs, no placeholders."),
1691
+ to: z.string().optional().describe("Override the recipient email address (else the entity's best email on record is used)."),
1692
+ rationale: z.string().optional().describe("One line on what the draft is based on — shown to the operator on the card."),
1693
+ },
1694
+ async ({ focus, subject, body, to, rationale }) => {
1695
+ try {
1696
+ const r = await post("/v2/drafts/email", { focus, subject, body, to, rationale });
1697
+ if (r.status === "ambiguous") {
1698
+ const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
1699
+ return { content: [{ type: "text", text: `"${focus}" matches several people. Re-call draft_email with one of these entity ids as focus:\n${opts}` }] };
1700
+ }
1701
+ return { content: [{ type: "text", text:
1702
+ `Email draft ready for ${r.recipient} <${r.to}> — it's in front of the operator to review and send from their own Gmail. You have not sent it.` }] };
1703
+ } catch (e) {
1704
+ const msg = /no_email/.test(e.message)
1705
+ ? "No email on file for that person. Ask the operator for the address (pass it as `to`), or draft a LinkedIn message instead."
1706
+ : /entity_not_found/.test(e.message)
1707
+ ? "Couldn't find that recipient on the record — check the focus identifier."
1708
+ : `Couldn't create the draft: ${e.message}`;
1709
+ return { content: [{ type: "text", text: msg }] };
1710
+ }
1711
+ }
1712
+ );
1713
+
1642
1714
  // ===========================================================================
1643
1715
  // TOOLS: the CORRECTION layer — unsay something recorded by mistake. `record` and
1644
1716
  // `save_note` are how you write; these are how you take it back. Both heal the
@@ -1699,28 +1771,6 @@ export function createServer() {
1699
1771
  // TOOLS: list + introspect — enumerate what's on the workspace, and who you are.
1700
1772
  // GET /v2/notes · /v2/workspace/integrations · /v2/workspace/members.
1701
1773
  // ===========================================================================
1702
- server.tool(
1703
- "list_notes",
1704
- "List saved notes newest-first (chronological), optionally scoped to one person/company with " +
1705
- "`focus`. This is the LIST companion to search_notes (which is semantic): use it for \"show me the " +
1706
- "notes on X\", or to enumerate notes and get their ids — e.g. to pick one to delete_note. Returns " +
1707
- "id, type, title, date and a snippet for each.",
1708
- {
1709
- focus: z.string().optional().describe("Restrict to one person/company (email, LinkedIn URL, entity UUID, or domain). Omit for the whole workspace."),
1710
- limit: z.number().int().min(1).max(100).optional().describe("Max notes to return (default 20)."),
1711
- },
1712
- async ({ focus, limit }) => {
1713
- const r = await get("/v2/notes", { ...(focus ? { focus } : {}), ...(limit ? { limit } : {}) });
1714
- if (r.status === "ambiguous") {
1715
- const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
1716
- return { content: [{ type: "text", text: `"${focus}" matches several — re-call list_notes with one of these entity ids as focus:\n${opts}` }] };
1717
- }
1718
- if (!r.notes?.length) return { content: [{ type: "text", text: focus ? "No notes on that account." : "No notes saved yet." }] };
1719
- const lines = r.notes.map(n => ` • ${n.title || n.type}${n.date ? ` (${n.date})` : ""} [${n.id}]\n ${n.snippet}`).join("\n");
1720
- return { content: [{ type: "text", text: `${r.notes.length} note(s)${r.has_more ? " (more available — raise limit)" : ""}:\n${lines}` }] };
1721
- }
1722
- );
1723
-
1724
1774
  server.tool(
1725
1775
  "list_integrations",
1726
1776
  "List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +
@@ -1754,29 +1804,5 @@ export function createServer() {
1754
1804
  }
1755
1805
  );
1756
1806
 
1757
- // ===========================================================================
1758
- // TOOL: get_routing_preferences
1759
- // The routing preferences that make THIS agent default to Nous for GTM. The
1760
- // agent applies them itself in Claude Code (write to CLAUDE.md); for claude.ai
1761
- // the user pastes them into Org preferences. Not applicable to Codex/others.
1762
- // ===========================================================================
1763
- server.tool(
1764
- "get_routing_preferences",
1765
- "Get the routing preferences that make this agent treat Nous as the DEFAULT for GTM — so it " +
1766
- "reaches for get_context / get_account instead of raw CRM or HubSpot / Salesforce / Gong / " +
1767
- "Granola when someone forgets to say \"Nous\". This is an OPTIONAL onboarding step, and " +
1768
- "CLAUDE CODE-specific. If you are running in Claude Code, set it up for the user: ask whether to " +
1769
- "apply it to this project (write the returned text to ./CLAUDE.md) or to all projects " +
1770
- "(~/.claude/CLAUDE.md), then write it there. The user can also paste it into claude.ai → Settings " +
1771
- "→ Organization preferences (Team/Enterprise) or Personal preferences (Pro). If you are NOT Claude " +
1772
- "Code (Codex, Cursor, n8n, …), this does not apply — skip it.",
1773
- {},
1774
- async () => {
1775
- return { content: [{ type: "text", text:
1776
- `Routing preferences (write to the user's CLAUDE.md in Claude Code, or have them paste into ` +
1777
- `claude.ai → Settings → Organization/Personal preferences):\n\n${ROUTING_PREFERENCES}` }] };
1778
- }
1779
- );
1780
-
1781
1807
  return server;
1782
1808
  }