@opennous/mcp 0.51.0 → 0.54.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/src/server.js CHANGED
@@ -10,25 +10,26 @@
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, 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 · list_notes · get_foundation · get_insights
17
- * WRITE record · record_signal · save_note · propose_vault_file · merge_contacts ·
18
- * sync_foundation
19
- * ACT draft_email · send_linkedin_message
20
- * CORRECT retract_observation · delete_note · unmerge_contacts
21
- * 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
13
+ * The revenue-plugin surface 7 primitives, and nothing else (legacy tools pruned):
14
+ * READ get_context · get_account · query (search folds in via facts:true) · score
15
+ * WRITE record · record_insight (you observe; the engine derives; no overwrites)
16
+ * IDENTITY whoami (who the key acts as · scope admin|member · GTM role[s])
25
17
  */
26
18
 
27
19
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
20
  import { z } from "zod";
29
21
  import { get, post, del } from "./client.js";
30
22
 
31
- export const SERVER_VERSION = "0.51.0";
23
+ // Public web-app URL for one-click connect deep links. OAuth providers (Gmail,
24
+ // LinkedIn) need a browser sign-in the agent can't perform headlessly, so instead
25
+ // of a dead-end the agent hands the user a link that lands on the Integrations page
26
+ // with that provider's connect flow pre-opened. Overridable via NOUS_APP_URL.
27
+ const APP_URL = () => (process.env.NOUS_APP_URL || "https://app.opennous.cloud").replace(/\/+$/, "");
28
+ // OAuth/browser-sign-in providers → the slug the Integrations page auto-opens on.
29
+ const OAUTH_CONNECT = { gmail: "gmail", gmail_oauth: "gmail", google: "gmail", "google-mail": "gmail", googlemail: "gmail", linkedin: "linkedin" };
30
+ const connectLink = (slug) => `${APP_URL()}/settings?section=integrations&connect=${slug}`;
31
+
32
+ export const SERVER_VERSION = "0.57.0";
32
33
 
33
34
  // ─── helpers ──────────────────────────────────────────────────────────────────
34
35
 
@@ -69,54 +70,6 @@ const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " "
69
70
  const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
70
71
  const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
71
72
 
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
73
 
121
74
  // ─── factory ──────────────────────────────────────────────────────────────────
122
75
 
@@ -125,20 +78,36 @@ export function createServer() {
125
78
  name: "nous",
126
79
  version: SERVER_VERSION,
127
80
  description:
128
- "Nous — the Context Graph for AI Agents. Nous is operated by the agent, not by a human " +
129
- "clicking around: call get_workspace_status at the start of a session to see what's set up " +
130
- "and what to set up next. Call get_context before drafting outreach or preparing for a " +
131
- "meeting. Call record after every interaction, or whenever you learn something.",
81
+ "Nous — the revenue Context Graph for your coding agent. Operated by the agent, not a human " +
82
+ "clicking around: call whoami to confirm who you act as (workspace, scope, GTM role). Call " +
83
+ "get_context before drafting outreach or preparing for a meeting. Call record after every " +
84
+ "interaction, or whenever you learn something — you observe, Nous derives.",
132
85
  icons: [
133
86
  { src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
134
87
  ],
135
88
  });
136
89
 
90
+ // Nous is fully a revenue plugin: this server exposes EXACTLY the 7 primitives and
91
+ // nothing else (the ~30 legacy tools were pruned). Writes: record · record_insight.
92
+ // Reads: get_context · get_account · query (search folds in via facts:true) · score.
93
+ // Identity: whoami (who the key acts as, scope, GTM role[s]) — the governance
94
+ // substrate. record_signal folds into record; save_note is out. The `tool()` guard
95
+ // keeps the surface locked to these seven — a stray non-primitive registration is
96
+ // dropped, not exposed. See docs/revenue-plugin/README.md §2–3.
97
+ const PLUGIN_TOOLS = new Set([
98
+ "record", "record_insight", "get_context", "get_account", "query", "score", "whoami",
99
+ ]);
100
+ const _tool = server.tool.bind(server);
101
+ const tool = (name, ...rest) => {
102
+ if (!PLUGIN_TOOLS.has(name)) return undefined;
103
+ return _tool(name, ...rest);
104
+ };
105
+
137
106
  // ===========================================================================
138
107
  // TOOL: get_context — POST /v2/context
139
108
  // The headline tool. Engineered, intent-shaped context for a specific task.
140
109
  // ===========================================================================
141
- server.tool(
110
+ tool(
142
111
  "get_context",
143
112
  "Get engineered context for a specific task about a person or company. Pass their email (or " +
144
113
  "entity id) and the intent. Returns a focused, ranked context block: the facts that matter for " +
@@ -246,7 +215,7 @@ export function createServer() {
246
215
  // TOOL: get_account — GET /v2/accounts/:id
247
216
  // The full account-record projection. For a focused view, prefer get_context.
248
217
  // ===========================================================================
249
- server.tool(
218
+ tool(
250
219
  "get_account",
251
220
  "Get the full account record for a person or company — the durable FACTS we've learned about them " +
252
221
  "(their atomic memory: budget, authority, pain, stack, plans), every attribute (claim) with its " +
@@ -370,96 +339,13 @@ export function createServer() {
370
339
  }
371
340
  );
372
341
 
373
- // ===========================================================================
374
- // TOOL: merge_contacts — POST /v2/accounts/merge
375
- // Fold a duplicate person into one account record. Agent-only dedup.
376
- // ===========================================================================
377
- server.tool(
378
- "merge_contacts",
379
- "Merge two duplicate records for the SAME person into one account. Use when the same human exists " +
380
- "twice — e.g. one record from a LinkedIn connection (no email) and one from a Cal.com booking (email, " +
381
- "truncated name) that never got linked. Pass `keep` (the survivor) and `drop` (the duplicate to fold in); " +
382
- "each may be an email, LinkedIn URL, entity UUID, or name. Lossless — the duplicate's identifiers (a second " +
383
- "email, a LinkedIn URL) re-attach to the survivor, so a future match on EITHER resolves to the one account — " +
384
- "and reversible. If a name matches several people you'll get candidates: confirm the survivor with the user, " +
385
- "then re-call with the chosen entity ids. Prefer passing the keep that already has the most history.",
386
- {
387
- keep: z.string().describe("The survivor to keep — email, LinkedIn URL, entity UUID, or name."),
388
- drop: z.string().describe("The duplicate to fold into keep — email, LinkedIn URL, entity UUID, or name."),
389
- },
390
- async ({ keep, drop }) => {
391
- const r = await post("/v2/accounts/merge", { keep, drop });
392
-
393
- if (r.status === "ambiguous") {
394
- const opts = (r.candidates ?? []).map(c =>
395
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
396
- const term = r.which === "keep" ? keep : drop;
397
- return { content: [{ type: "text", text:
398
- `"${term}" (the ${r.which}) matches several people. Re-call merge_contacts with one of these entity ids as ${r.which}:\n${opts}` }] };
399
- }
400
-
401
- const moved = Object.entries(r.rows_repointed ?? {}).map(([t, n]) => `${n} ${t}`).join(", ");
402
- const lines = [
403
- `Merged — folded ${r.drop_id} into ${r.keep_id}.`,
404
- ` identifiers re-attached: ${r.identifiers_moved} (a future match on either now resolves to one account)`,
405
- ` claims moved: ${r.claims_moved}${r.claims_conflicted ? ` (${r.claims_conflicted} kept on survivor)` : ""}`,
406
- ` observations moved: ${r.observations_moved}`,
407
- (r.relationships_repointed || r.relationships_removed)
408
- ? ` relationships: ${r.relationships_repointed} re-pointed, ${r.relationships_removed} pruned` : null,
409
- moved ? ` re-pointed: ${moved}` : null,
410
- `Reversible: if this was wrong, unmerge_contacts with drop_id "${r.drop_id}" puts it all back.`,
411
- ].filter(Boolean);
412
- return { content: [{ type: "text", text: lines.join("\n") }] };
413
- }
414
- );
415
342
 
416
- server.tool(
417
- "unmerge_contacts",
418
- "REVERSE a merge — split a wrongly-merged duplicate back out into its own account. Identify the " +
419
- "merge to undo either by `drop_id` (the tombstone's id, exactly as merge_contacts reported it) or by " +
420
- "`keep` (an identifier for the survivor — undoes the MOST RECENT merge into it). Every re-pointed " +
421
- "identifier, claim, observation, relationship and the record itself go back where they were. " +
422
- "Forward-only: it can only reverse merges made after reversible-merge tracking existed — an older " +
423
- "merge returns a clear 'not reversible'. Use this when two DIFFERENT people were merged by mistake; " +
424
- "it is not for editing a correctly-merged account.",
425
- {
426
- drop_id: z.string().optional().describe("The merged-away entity's id, from the merge_contacts result. Provide this OR keep."),
427
- keep: z.string().optional().describe("The survivor (email, LinkedIn URL, entity UUID, or name) — undoes the most recent un-reversed merge into it."),
428
- },
429
- async ({ drop_id, keep }) => {
430
- if (!drop_id && !keep) {
431
- return { content: [{ type: "text", text: "Give me the drop_id from the merge result, or the `keep` survivor whose last merge to undo." }] };
432
- }
433
- try {
434
- const r = await post("/v2/accounts/unmerge", { drop_id, keep });
435
- if (r.status === "ambiguous") {
436
- const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
437
- return { content: [{ type: "text", text: `"${keep}" matches several people. Re-call unmerge_contacts with one of these entity ids as keep:\n${opts}` }] };
438
- }
439
- const lines = [
440
- `Un-merged — ${r.drop_id} is its own account again.`,
441
- ` identifiers restored: ${r.identifiers}, claims: ${r.claims}, observations: ${r.observations}, relationships: ${r.relationships}`,
442
- r.contact_restored ? ` the contact record was recreated.` : null,
443
- ].filter(Boolean);
444
- return { content: [{ type: "text", text: lines.join("\n") }] };
445
- } catch (e) {
446
- const msg = /not_reversible/.test(e.message)
447
- ? "That merge can't be reversed — it predates reversible-merge tracking, or it was already un-merged."
448
- : /no_reversible_merge/.test(e.message)
449
- ? "No un-reversed merge on that survivor to undo."
450
- : /entity_not_found/.test(e.message)
451
- ? "Couldn't find that survivor — check the keep identifier."
452
- : `Couldn't un-merge: ${e.message}`;
453
- return { content: [{ type: "text", text: msg }] };
454
- }
455
- }
456
- );
457
343
 
458
344
  // ===========================================================================
459
345
  // TOOL: record — POST /v2/observations
460
346
  // The single write verb. You observe — Nous derives the updated facts.
461
347
  // ===========================================================================
462
- server.tool(
348
+ tool(
463
349
  "record",
464
350
  "Record what happened or what you learned about a person or company. You never overwrite " +
465
351
  "anything — you observe, and Nous derives the updated facts. Use kind:'event' for an interaction " +
@@ -467,14 +353,24 @@ export function createServer() {
467
353
  "kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
468
354
  "email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
469
355
  "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}.",
356
+ "a fact ended (they left): {kind:'state',property:'job_title',value:null}. For a piece of INTEL " +
357
+ "about the contact — a preference, an objection, a competitor they mentioned, a general note — " +
358
+ "use property:'intel' with a category: {kind:'state',property:'intel',value:{category:'objection'," +
359
+ "content:'skeptical about market demand',label:'Market demand skepticism'}} (category ∈ " +
360
+ "preference | objection | competitor | buying_signal | budget | timing | authority | general). " +
361
+ "Insights about YOUR OWN business (product/positioning/market/buyer) go to record_insight, not here.",
471
362
  {
472
- focus: z.string().describe("Email address or entity UUID of the person or company"),
363
+ focus: z.string().describe("Email address, LinkedIn URL, domain, or entity UUID a precise identifier, never a bare name"),
473
364
  observations: z.array(z.object({
474
365
  kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
475
366
  property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
476
367
  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)"),
368
+ source: z.string().optional().describe("where this came from — provider slug, e.g. 'fireflies', 'gmail' (default: agent)"),
369
+ method: z.string().optional().describe("api | webhook | extraction | inference | user_input (default: api)"),
370
+ observed_at: z.string().optional().describe("ISO timestamp of WHEN this happened/was true. REQUIRED when importing history — otherwise it's stamped now."),
371
+ 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>'."),
372
+ 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."),
373
+ 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
374
  })).describe("One or more observations to record"),
479
375
  },
480
376
  async ({ focus, observations }) => {
@@ -488,47 +384,39 @@ export function createServer() {
488
384
  }
489
385
  );
490
386
 
387
+
491
388
  // ===========================================================================
492
- // TOOL: record_signala buying signal, as a structured signal.<class> fact
493
- // A validated wrapper over record: one canonical way to write a signal, so it
494
- // both shows on the account's Signals tab AND feeds the ICP scorecard as a
495
- // feature (signal.* claims flow into the feature map the scorer reads).
389
+ // TOOL: record_insightPOST /v2/insights
390
+ // The mirror of record. record captures facts ABOUT the contact; this captures
391
+ // what a call/email taught us about OUR OWN business product, positioning,
392
+ // market, buyer. Workspace-level (about us, no focus). Feeds the Insights page
393
+ // and theme clustering. In the plugin model the extraction runs on the agent's
394
+ // own tokens, so this is where those insights land.
496
395
  // ===========================================================================
497
- server.tool(
498
- "record_signal",
499
- "Record a buying signal on a person or company a concrete, current reason to reach out, " +
500
- "found by research (signal-scan). Stored as a structured signal.<class> fact so it shows on the " +
501
- "account's Signals tab AND feeds the ICP scoring model as a feature. One call per signal; one " +
502
- "current signal per class (the strongest). class is one of stack | hiring | momentum | friction | " +
503
- "intent | domain. score is 0-10 (exclusivity x intent score honestly, a 4 is useful). Be " +
504
- "specific: 'posted 3 SDR roles in 30 days', not 'they're growing'.",
396
+ tool(
397
+ "record_insight",
398
+ "Record what a call or email taught us about OUR OWN business our product, positioning, " +
399
+ "market, or buyer. This is the MIRROR of record: record captures facts about the CONTACT; this " +
400
+ "captures what we learned about US. Workspace-level there is no focus (it's not about one " +
401
+ "account). category is one of product | positioning | market | buyer. Include the verbatim quote " +
402
+ "and who said it whenever you can. These feed the Insights page and its theme clustering. Send " +
403
+ "all insights from one source in a single call. Do NOT put contact facts here — use record.",
505
404
  {
506
- focus: z.string().describe("Email address or entity UUID of the person/company"),
507
- signal_class: z.enum(["stack", "hiring", "momentum", "friction", "intent", "domain"])
508
- .describe("the signal class"),
509
- detected: z.string().describe("the specific, factual finding"),
510
- implies: z.string().optional().describe("what the prospect is likely experiencing because of it"),
511
- score: z.number().min(0).max(10).describe("strength 0-10 (exclusivity x intent)"),
512
- approach: z.enum(["pain_led", "value_led", "fallback"]).optional()
513
- .describe("recommended outreach approach"),
514
- angle: z.string().optional().describe("one-line outreach angle this signal enables"),
405
+ insights: z.array(z.object({
406
+ category: z.enum(["product", "positioning", "market", "buyer"]).describe("which lens this is about"),
407
+ content: z.string().describe("the insight in one clear sentence, stated from OUR perspective"),
408
+ quote: z.string().optional().describe("the verbatim thing they said that supports it"),
409
+ speaker: z.string().optional().describe("who said it a name or a role"),
410
+ })).describe("one or more insights extracted from the same call/email"),
411
+ source_label: z.string().optional().describe("where these came from, e.g. 'Acme discovery call'"),
412
+ occurred_at: z.string().optional().describe("ISO timestamp of the call/email"),
515
413
  },
516
- async ({ focus, signal_class, detected, implies, score, approach, angle }) => {
517
- const result = await post("/v2/observations", {
518
- focus,
519
- observations: [{
520
- kind: "state",
521
- property: `signal.${signal_class}`,
522
- value: { detected, implies: implies ?? null, score, approach: approach ?? null, angle: angle ?? null },
523
- source: "signal-scan",
524
- }],
525
- });
526
- return {
527
- content: [{
528
- type: "text",
529
- text: `Recorded ${signal_class} signal (score ${score}/10) on ${result.entity_id || focus}.`,
530
- }],
531
- };
414
+ async ({ insights, source_label, occurred_at }) => {
415
+ const r = await post("/v2/insights", { insights, source_label, occurred_at });
416
+ return { content: [{ type: "text", text:
417
+ `Recorded ${r.written} insight${r.written !== 1 ? "s" : ""}` +
418
+ (r.submitted && r.written < r.submitted ? ` (${r.submitted - r.written} were duplicates)` : "") +
419
+ ` into the workspace's product/positioning/market/buyer docs.` }] };
532
420
  }
533
421
  );
534
422
 
@@ -536,7 +424,7 @@ export function createServer() {
536
424
  // TOOL: query — POST /v2/query
537
425
  // Retrieve a corpus of activity across many people. You do the analysis.
538
426
  // ===========================================================================
539
- server.tool(
427
+ tool(
540
428
  "query",
541
429
  "Retrieve and summarise activity across many people. Three powers:\n" +
542
430
  " 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
@@ -566,6 +454,8 @@ export function createServer() {
566
454
  order: z.enum(["asc", "desc"]).optional().describe("observed_at order (default desc, newest first). Use 'asc' for an upcoming-meeting schedule (soonest first)"),
567
455
  limit: z.number().optional().describe("max items (default 50, cap 200)"),
568
456
  facts: z.boolean().optional().describe("search the FACTS corpus (durable atomic facts about accounts) instead of activity. Needs `question` — a cross-account semantic fact search, e.g. 'which accounts want off Clay'. return:'entities' = the best matching fact per account."),
457
+ reporting: z.enum(["company", "role"]).optional().describe("Return the distilled REPORTING for a seat instead of activity: 'role' = the insights routed to a GTM role (deal-blockers/objections with account counts + relevant themes, reframed for that seat); 'company' = the whole-company founder lens. Routed server-side by the caller's role — a member only ever gets their own seat, an admin/founder can ask for 'company' or any role."),
458
+ role: z.string().optional().describe("With reporting:'role', which seat to report for — account_executive | sdr | sales | customer_success | engineer | marketing | revops | founder. Defaults to the caller's own role(s) from whoami."),
569
459
  }).describe("Corpus filter"),
570
460
  without: z.object({
571
461
  kind: z.enum(["event", "state"]).optional(),
@@ -583,6 +473,28 @@ export function createServer() {
583
473
  if (without) body.without = without;
584
474
  if (returnMode) body.return = returnMode;
585
475
  const r = await post("/v2/query", body);
476
+
477
+ // Reporting response (scope.reporting) — distilled handlers + themes for a seat.
478
+ if (r.role_label !== undefined || r.reporting) {
479
+ const out = [`REPORTING · ${r.role_label || r.role}${r.reporting === "company" ? " (whole-company lens)" : ""}`, ""];
480
+ if ((r.handlers || []).length) {
481
+ out.push("DEAL BLOCKERS / OBJECTIONS (by accounts raising them):");
482
+ for (const h of r.handlers) {
483
+ out.push(` • ${h.title}${h.account_count ? ` — ${h.account_count} account${h.account_count !== 1 ? "s" : ""}` : ""}` +
484
+ (h.objection_summary ? `\n ${h.objection_summary}` : ""));
485
+ }
486
+ out.push("");
487
+ }
488
+ if ((r.themes || []).length) {
489
+ out.push("SIGNALS / THEMES for this seat:");
490
+ for (const t of r.themes) out.push(` • ${t.title}${t.takeaway ? ` — ${t.takeaway}` : ""}`);
491
+ }
492
+ if (!(r.handlers || []).length && !(r.themes || []).length) {
493
+ out.push("Nothing distilled for this seat yet — run some ingest/backfill so the reporting has material.");
494
+ }
495
+ return { content: [{ type: "text", text: out.join("\n").trim() }] };
496
+ }
497
+
586
498
  const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
587
499
  (r.sampled ? ` (showing ${r.returned})` : "") +
588
500
  (r.corpus === "facts" ? " · facts" : r.return === "entities" ? " · grouped by entity" : "");
@@ -626,1192 +538,137 @@ export function createServer() {
626
538
  const names = (r.candidates || []).map(c => c.name || c.entity_id).join(", ");
627
539
  return `ambiguous — several people match${names ? `: ${names}` : ""}. Score by email or LinkedIn URL instead.`;
628
540
  }
629
- return "not in the graph yet — enrich this account first (signals/firmographics), then score.";
541
+ return "not in the graph yet — pass the lead's `attributes` (title, company, keywords) to score it inline, or enrich the account first.";
542
+ }
543
+ const layers = r.layered && r.layered.layers ? r.layered.layers : {};
544
+ const layerLine = Object.keys(layers).length ? `\n layers: ${Object.entries(layers).map(([k, v]) => `${k} ${v}`).join(" · ")}` : "";
545
+ const work = (r.layered && r.layered.missing && r.layered.missing.length)
546
+ ? `\n unknown: ${r.layered.missing.join(", ")} — ${r.layered.play || "enrich to resolve"}` : "";
547
+ if (!r.scored) {
548
+ // 'partial' — Fit/Pain resolved live (not staked). Show what we know + the worklist.
549
+ if (Object.keys(layers).length) return `partial fit${layerLine}${work}`;
550
+ return `known, but awaiting enrichment — no scoreable claims yet (${r.entity_id}).`;
630
551
  }
631
- if (!r.scored) return `known, but awaiting enrichment — no scoreable claims yet (${r.entity_id}).`;
632
552
  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}` : "");
553
+ const inline = r.source === "inline" ? " · scored inline (not in the graph)" : "";
554
+ return `ICP ${r.icp.score}/100 (${tier})${r.icp.fit ? " ✓fit" : ""} · intent ${r.intent.score}/100 ${r.intent.band}${inline}` +
555
+ layerLine + work + (r.icp.reason ? `\n ${r.icp.reason}` : "");
635
556
  };
636
- server.tool(
557
+ tool(
637
558
  "score",
638
559
  "Score a lead against our live ICP model and intent axis, and write the judgment into the graph so " +
639
560
  "every other agent reads the same number. This is for scoring a list the user built ELSEWHERE (a " +
640
561
  "Google Sheet, a Clay column, a CRM export): the list stays where it is, Nous returns the score. " +
641
562
  "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.",
563
+ "Returns ICP fit 0-100 + tier (tier_1/2/3/not_icp, which drives the play), decaying intent " +
564
+ "0-100 + band, AND the LAYERED read: layers {fit,pain,intent,ability} with a `missing` worklist " +
565
+ "(what's still unknown unknown is never 0) and a suggested `play`. It reads the live score " +
566
+ "(staking one on demand for a known-but-unscored account); the score keeps evolving afterwards. " +
567
+ "COLD LEAD not in Nous? Pass `attributes` (title, company, keywords/headline) and it's scored " +
568
+ "INLINE against the model — Fit/Pain resolve from what you have, nothing is written. For a whole " +
569
+ "EXTERNAL list, pass `leads` (up to 1000 attribute objects) — all scored inline in one call, model " +
570
+ "loaded once, nothing written — so you can triage a big list before deciding what to bring in. Only " +
571
+ "a bare identifier with no attributes and no graph record comes back `unknown_identifier`.",
646
572
  {
647
573
  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)."),
574
+ identifiers: z.array(z.string()).optional().describe("A batch of graph leads (max 100 per call; loop for a larger list)."),
575
+ 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."),
576
+ 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
577
  intent: z.string().optional().describe("Optional hint about why you're scoring (recorded, does not change the score)."),
650
578
  },
651
- async ({ identifier, identifiers, intent }) => {
579
+ async ({ identifier, identifiers, intent, attributes, leads }) => {
580
+ if (Array.isArray(leads) && leads.length) {
581
+ const r = await post("/v2/score", { leads });
582
+ const rows = r.results || [];
583
+ const scored = rows.filter(x => x.scored);
584
+ const byTier = {};
585
+ for (const x of scored) { const t = (x.icp?.tier || "untiered").replace(/_/g, " "); byTier[t] = (byTier[t] || 0) + 1; }
586
+ const dist = Object.entries(byTier).sort((a, b) => b[1] - a[1]).map(([t, n]) => `${t} ${n}`).join(" · ") || "none";
587
+ return { content: [{ type: "text", text: `Scored ${scored.length}/${rows.length} inline (nothing written). Tiers: ${dist}` }] };
588
+ }
652
589
  if (Array.isArray(identifiers) && identifiers.length) {
653
590
  const r = await post("/v2/score", { identifiers, intent });
654
591
  const lines = (r.results || []).map(x => ` ${x.identifier} — ${scoreOne(x)}`);
655
592
  const scored = (r.results || []).filter(x => x.scored).length;
656
593
  return { content: [{ type: "text", text: `Scored ${scored}/${(r.results || []).length}:\n${lines.join("\n")}` }] };
657
594
  }
658
- const r = await post("/v2/score", { identifier, intent });
659
- return { content: [{ type: "text", text: `${identifier} — ${scoreOne(r)}` }] };
595
+ const r = await post("/v2/score", { identifier, intent, attributes });
596
+ return { content: [{ type: "text", text: `${identifier || "cold lead"} — ${scoreOne(r)}` }] };
660
597
  }
661
598
  );
662
599
 
663
- // ===========================================================================
664
- // TOOL: attention — GET /v2/attention
665
- // What to look at: accounts gone quiet, key facts decayed.
666
- // ===========================================================================
667
- server.tool(
668
- "attention",
669
- "What needs your attention across the workspace right now — upcoming meetings and calls in the " +
670
- "next 7 days (each with its date and time, soonest first), accounts that have gone quiet, and key " +
671
- "facts that have decayed. Returns ranked items (time-critical meetings lead), each with what's " +
672
- "happening and a suggested action. Call this to decide what to work next, or to answer 'what's " +
673
- "coming up' / 'what's on my calendar this week'. For a precise single-day list, use query with " +
674
- "property:'interaction.meeting_scheduled' and from/to.",
675
- {
676
- limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
677
- },
678
- async ({ limit }) => {
679
- const r = await get("/v2/attention", limit ? { limit } : {});
680
- if (!r.items?.length) {
681
- return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
682
- }
683
- // Upcoming meetings carry a `when` — render the absolute local date+time.
684
- //
685
- // Each item also names where it came from: the calendar holding the call, the
686
- // transcript the promise was captured from. An agent that can cite the call
687
- // someone made a promise ON is making an argument; one that just asserts the
688
- // promise is asking to be trusted.
689
- const lines = r.items.map(it => {
690
- const when = it.when ? `${fmtWhen(it.when)} — ` : "";
691
- const from = it.source ? ` [${it.source}]` : "";
692
- return ` ${when}${it.entity_name ?? it.entity_id} — ${it.what}${from}\n → ${it.suggested_action}`;
693
- });
694
- return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
695
- }
696
- );
697
600
 
698
- // ===========================================================================
699
- // TOOL: campaign_performance — GET /v2/campaigns/performance
700
- // The aggregate outbound feedback loop: which campaign/variant earns positive
701
- // replies, and from good-fit accounts.
702
- // ===========================================================================
703
- server.tool(
704
- "campaign_performance",
705
- "Outbound campaign feedback loop: how each cold-email/LinkedIn campaign and sequence step is landing, " +
706
- "sliced by reply sentiment and the ICP tier of who replied positively. Use to answer 'which campaign " +
707
- "or variant gets the most positive replies?', 'is my best campaign landing on good-fit accounts?', or " +
708
- "'which copy should I scale vs cut?'. positive_rate is positive replies / total replies (not / sent).",
709
- {},
710
- async () => {
711
- const r = await get("/v2/campaigns/performance", {});
712
- const camps = r.campaigns || [];
713
- if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
714
- const t = r.totals || {};
715
- const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
716
- const lines = camps.map(c => {
717
- const cap = c.captured || {};
718
- const pt = c.provider_totals, pr = c.provider_rates;
719
- const tiers = c.tier_of_positive || {};
720
- const good = (tiers.tier_1 || 0) + (tiers.tier_2 || 0);
721
- const funnel = pt
722
- ? ` funnel: ${pt.sent ?? "?"} sent → ${pt.contacted ?? "?"} contacted → ${pt.replies ?? "?"} replies (${pct(pr?.reply_rate)}) → ${pt.opportunities ?? "?"} opportunities (${pct(pr?.opportunity_rate)})`
723
- : ` funnel: (no provider analytics yet)`;
724
- 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` : ""}`;
725
- const steps = (c.steps || []).map(s => {
726
- const subj = s.subject ? ` — "${s.subject}"` : "";
727
- return ` step ${s.step ?? "—"}${subj}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`;
728
- }).join("\n");
729
- const obj = (c.top_objections || []).length
730
- ? `\n objections/pains: ${c.top_objections.slice(0, 4).map(o => `“${String(o).slice(0, 90)}”`).join("; ")}`
731
- : "";
732
- return ` ${c.campaign_name || c.campaign_id}\n` +
733
- funnel + "\n" +
734
- ` captured sample: ${cap.replies} replies — ${cap.positive} positive (${pct(cap.positive_rate)}), ${cap.neutral} neutral, ${cap.negative} negative\n` +
735
- ` converted: ${conv}\n` +
736
- ` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${cap.positive}` +
737
- (steps ? `\n${steps}` : "") + obj;
738
- });
739
- const head = `Campaign performance (${t.conversions || 0} clients · ${t.sent || 0} sent · ${t.positive || 0}/${t.replies || 0} captured replies positive):`;
740
- return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
741
- }
742
- );
743
601
 
744
- // ===========================================================================
745
- // TOOL: pipeline_intelligence — GET /v2/pipeline/intelligence
746
- // What converts, and how long it takes: stage distribution, stage-to-stage
747
- // conversion %, median time-to-client, median time-in-stage.
748
- // ===========================================================================
749
- server.tool(
750
- "pipeline_intelligence",
751
- "How the pipeline actually converts: how many accounts sit at each stage right now, the stage-to-stage " +
752
- "conversion rate (of everyone who reached a stage, how many reached the next — where deals leak), the " +
753
- "median days to turn a lead into a client, and the median time spent in each stage. Use to answer 'how " +
754
- "long does it take us to close?', 'where are deals stalling?', or 'what's my funnel look like?'.",
755
- {},
756
- async () => {
757
- const r = await get("/v2/pipeline/intelligence", {});
758
- const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
759
- const cc = r.current_stage_counts || {};
760
- // The funnel, lowest → highest, then the terminals. SOURCE OF TRUTH:
761
- // PIPELINE_LADDER + TERMINAL_STAGES in packages/core/src/pipeline.ts. This
762
- // package publishes standalone and has no @nous/core dependency, so the
763
- // list is copied here. Keep it in step with core.
764
- const order = [
765
- "identified", "connected", "interested", "meeting_booked",
766
- "discovery", "demo", "negotiation", "closed_won",
767
- "closed_lost", "disqualified", "churned",
768
- ];
769
- // Ladder order first, then any stage the API sent that this copy doesn't
770
- // know about. The old list had no proposal, negotiation or demo in it and
771
- // the filter silently dropped everything it missed, so late-funnel accounts
772
- // disappeared from the distribution entirely. A stage must never vanish
773
- // just because a copy of the list went stale.
774
- const byStage = (obj, keep) => [
775
- ...order.filter(s => keep(obj[s])),
776
- ...Object.keys(obj).filter(s => !order.includes(s) && keep(obj[s])),
777
- ];
778
- const dist = byStage(cc, v => !!v).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
779
- const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
780
- const dis = r.median_days_in_stage || {};
781
- const inStage = byStage(dis, v => v != null).map(s => `${s} ${dis[s]}d`).join(" · ");
782
- const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
783
- ` now: ${dist}\n` +
784
- ` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
785
- ` stage-to-stage conversion:\n${conv || " (none yet)"}\n` +
786
- (inStage ? ` median time in stage: ${inStage}` : "");
787
- return { content: [{ type: "text", text }] };
788
- }
789
- );
790
602
 
791
- // ===========================================================================
792
- // TOOL: pipeline — GET /v2/pipeline/portfolio
793
- // The whole account portfolio in one call: health/band, ICP fit, stage,
794
- // days-quiet, open flags, open objections, live competitors, engagement trend,
795
- // multi-threading. The "reason over my whole book" tool.
796
- // ===========================================================================
797
- server.tool(
798
- "pipeline",
799
- "The whole ACCOUNT PORTFOLIO in one call — every in-touch account with its deal health + band, " +
800
- "ICP fit, pipeline stage, days-quiet, open re-engagement flags, open-objection count, live competitors, " +
801
- "engagement trend (rising/steady/cooling/cold), and multi-threading, plus a stage-count breakdown. Use " +
802
- "for any portfolio question: 'which accounts are cooling', 'who should I re-engage', 'where is competitive " +
803
- "risk highest', 'what's slipping', 'great-fit deals gone cold', 'analyze my pipeline'. Reason over the list.",
804
- { limit: z.number().optional().describe("Cap on accounts returned (default: all, most-actionable first)") },
805
- async (input) => {
806
- const r = await get("/v2/pipeline/portfolio", input.limit ? { limit: input.limit } : {});
807
- const stages = Object.entries(r.stage_counts || {}).map(([s, n]) => `${s} ${n}`).join(" · ");
808
- const rows = (r.accounts || []).slice(0, input.limit || 60).map(a => {
809
- const bits = [
810
- a.name,
811
- `health ${a.health ?? "—"} (${a.band})`,
812
- a.icp != null ? `ICP ${a.icp}` : null,
813
- `stage ${a.stage}`,
814
- a.days_quiet != null ? `quiet ${a.days_quiet}d` : null,
815
- `engagement ${a.engagement_trend}`,
816
- a.single_threaded ? "single-threaded" : `${a.engaged_contacts}/${a.total_contacts} engaged`,
817
- a.open_objections ? `${a.open_objections} open objection${a.open_objections === 1 ? "" : "s"}` : null,
818
- a.competitors?.length ? `competitors: ${a.competitors.join(", ")}` : null,
819
- a.flags?.length ? `flags: ${a.flags.join(", ")}` : null,
820
- ].filter(Boolean);
821
- return ` • ${bits.join(" — ")}`;
822
- }).join("\n");
823
- const text = `Portfolio (${r.total || 0} in-touch accounts). By stage: ${stages || "—"}\n${rows || " (none)"}`;
824
- return { content: [{ type: "text", text }] };
825
- }
826
- );
827
603
 
828
- // ===========================================================================
829
- // TOOL: get_action_items — GET /v2/action-items
830
- // Commitments extracted from meetings/emails — what you owe each account.
831
- // ===========================================================================
832
- server.tool(
833
- "get_action_items",
834
- "Your open action items and commitments, pulled from meeting notes and emails — what you owe " +
835
- "which account (and what they owe you), so you don't have to dig through transcripts. Use for " +
836
- "'what are my action items', 'what do I owe <account>', 'what's outstanding this week'. Defaults " +
837
- "to YOUR open items across all accounts, grouped by account.",
838
- {
839
- owner: z.enum(["me", "prospect", "all"]).optional().describe("Whose commitments — me (default), the prospect, or all"),
840
- status: z.enum(["open", "done", "all"]).optional().describe("open (default), done, or all"),
841
- focus: z.string().optional().describe("Scope to one account — an email or entity UUID"),
842
- due: z.enum(["today", "week", "all"]).optional().describe("Only items due today / this week (items that carry a due date) — default all"),
843
- },
844
- async ({ owner, status, focus, due }) => {
845
- const params = {};
846
- if (owner) params.owner = owner;
847
- if (status) params.status = status;
848
- if (focus) params.focus = focus;
849
- if (due) params.due = due;
850
- const r = await get("/v2/action-items", params);
851
- const items = r.items ?? [];
852
- if (!items.length) return { content: [{ type: "text", text: "No matching action items." }] };
853
604
 
854
- const byAccount = new Map();
855
- for (const it of items) {
856
- const key = it.account || it.account_email || it.entity_id || "—";
857
- if (!byAccount.has(key)) byAccount.set(key, []);
858
- byAccount.get(key).push(it);
859
- }
860
- const lines = [`${items.length} action item${items.length !== 1 ? "s" : ""}:`];
861
- for (const [account, list] of byAccount) {
862
- lines.push(`\n${account}:`);
863
- for (const it of list) {
864
- const who = it.owner_kind === "prospect" ? "[them]" : "[you]";
865
- const when = it.due_at ? ` (due ${fmtWhen(it.due_at)})` : "";
866
- lines.push(` ${who} ${it.title}${when}`);
867
- }
868
- }
869
- return { content: [{ type: "text", text: lines.join("\n") }] };
870
- }
871
- );
872
605
 
873
- // ===========================================================================
874
- // TOOL: verify — POST /v2/verify
875
- // Re-check a fact before acting on it — the calibration check.
876
- // ===========================================================================
877
- server.tool(
878
- "verify",
879
- "Re-check a specific fact before you act on it — e.g. an email or a deal stage that looks stale " +
880
- "in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
881
- "current evidence, and tells you whether it is still unverified.",
882
- {
883
- focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
884
- property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'pipeline_stage'"),
885
- },
886
- async ({ focus, property }) => {
887
- const r = await post("/v2/verify", { focus, property });
888
- if (r.status === "ambiguous") {
889
- const opts = (r.candidates ?? []).map(c =>
890
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
891
- return { content: [{ type: "text", text:
892
- `"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
893
- }
894
- const a = r.after ?? {};
895
- return { content: [{ type: "text", text:
896
- `${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
897
- }
898
- );
899
606
 
900
- // ===========================================================================
901
- // 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
904
- // every agent obeys the same rules. GET/POST /v2/foundations.
905
- // ===========================================================================
906
- const getFoundationSchema = {
907
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).optional()
908
- .describe("Which policy to read. Omit to list all four."),
909
- };
910
- const getFoundationHandler = async ({ kind }) => {
911
- const r = await get("/v2/foundations", kind ? { kind } : undefined);
912
- const pbs = r.foundations || [];
913
- if (!pbs.length) return { content: [{ type: "text", text:
914
- "No foundations set up yet. The user can set them up on the Foundations page or in their context files." }] };
915
- if (kind) {
916
- const pb = pbs[0];
917
- const src = pb.source === "claude_code" ? `mirrors ${pb.file_path}` : "stored in Nous";
918
- return { content: [{ type: "text", text:
919
- `# ${pb.title} — ${pb.kind} foundation (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
920
- }
921
- const lines = pbs.map(p => ` ${p.kind.padEnd(12)} ${p.title} (${p.source === "claude_code" ? p.file_path : "stored in Nous"})`);
922
- return { content: [{ type: "text", text:
923
- "The user's foundations (read one with get_foundation(kind)):\n" + lines.join("\n") }] };
924
- };
925
- 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.",
930
- getFoundationSchema, getFoundationHandler);
931
607
 
932
- const syncFoundationSchema = {
933
- kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which foundation to update."),
934
- 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
- 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
- };
937
- const syncFoundationHandler = async ({ kind, body_md, file_path }) => {
938
- const r = await post(`/v2/foundations/${kind}`, { body_md, file_path });
939
- return { content: [{ type: "text", text:
940
- `Synced the ${r.foundation?.kind || kind} foundation into Nous (v${r.foundation?.version}). Other agents now read the same rules.` }] };
941
- };
942
- server.tool("sync_foundation",
943
- "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 " +
945
- "file's new content and its path, so Nous mirrors it and every other agent obeys the same rules. An " +
946
- "edited foundation file that isn't synced is silently inert — other agents keep reading the old rules. " +
947
- "MIRROR, DO NOT REWRITE: when the user already has a foundation file, sync it AS-IS. Their file is the " +
948
- "author and Nous is the mirror — always pass file_path so the next sync knows where an in-app edit " +
949
- "lands. 'Improving' their wording on the way through means the copy in Nous silently disagrees with " +
950
- "the copy in their repo, and they will trust neither. If a file looks wrong, SAY SO; don't fix it in " +
951
- "transit. " +
952
- "(For the ICP/context files specifically, sync_icp is the sync — use that one.)",
953
- syncFoundationSchema, syncFoundationHandler);
608
+ // (Retired 2026-08-22) propose_vault_file — the agent no longer authors files into a
609
+ // member's personal vault. The vault surface is now the Sync page only (connect a repo);
610
+ // Nous auto-pushes account/people EVIDENCE to `nous/` in that repo, and agent-authored
611
+ // knowledge lives Nous-side (save_note on the account record), not as approval-gated files.
612
+ // The inbox approval flow was retired with it. See AIOS decisions/log.md 2026-08-22.
954
613
 
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
614
 
987
- // ===========================================================================
988
- // TOOL: save_note — POST /v2/notes
989
- // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
990
- // transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
991
- // contact builds a record across meetings. Distinct from `record` (which logs
992
- // that an interaction happened) — this keeps the document itself.
993
- // ===========================================================================
994
- server.tool(
995
- "save_note",
996
- "Save a note or document onto a person or company so it is kept on their record — a meeting " +
997
- "brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
998
- "you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
999
- "after writing a meeting brief, save it to the contact so future meetings can reference it). " +
1000
- "Notes are append-only and dated, so a contact builds a record across meetings — later you can " +
1001
- "read the last few and see what changed. This is NOT for logging that an interaction happened " +
1002
- "(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
1003
- "(that lives in their context files — sync it with `sync_icp`). Put the full text in `content` — it's kept for agents to read; the " +
1004
- "UI shows the title and date, not the whole body.",
1005
- {
1006
- focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
1007
- content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
1008
- type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
1009
- .optional().describe("What kind of document this is (default: note)."),
1010
- title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
1011
- date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
1012
- },
1013
- async ({ focus, content, type, title, date }) => {
1014
- const r = await post("/v2/notes", { focus, content, type, title, date });
1015
- const label = title || (r.doc_type || "note").replace(/_/g, " ");
1016
- return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
1017
- },
1018
- );
1019
615
 
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
- );
1049
616
 
1050
- // ===========================================================================
1051
- // TOOL: search_notes — POST /v2/notes/search
1052
- // Semantic search over saved notes & documents (briefs, transcripts, notes).
1053
- // The retrieval counterpart to save_note — pull relevant document content
1054
- // instead of dumping whole documents into context.
1055
- // ===========================================================================
1056
- server.tool(
1057
- "search_notes",
1058
- "Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
1059
- "kept on contacts. Use this to pull relevant content from the record — e.g. 'what did we discuss " +
1060
- "about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
1061
- "Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
1062
- "matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
1063
- {
1064
- question: z.string().describe("Natural-language query to match against document content."),
1065
- focus: z.string().optional().describe("Optional — restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
1066
- limit: z.number().optional().describe("Max documents to return (default 8)."),
1067
- },
1068
- async ({ question, focus, limit }) => {
1069
- const r = await post("/v2/notes/search", { question, focus, limit });
1070
- if (!r.documents?.length) {
1071
- return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
1072
- }
1073
- const lines = [`Documents matching "${question}":`, ""];
1074
- for (const d of r.documents) {
1075
- const when = d.date ? ` [${relAge(d.date)}]` : "";
1076
- // similarity is null for recency-matched hits (a note too fresh to be
1077
- // embedded yet) — label those "recent" instead of a bogus 0%.
1078
- const match = d.similarity == null ? "recent" : pct(d.similarity);
1079
- lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${match})${when}`);
1080
- if (d.snippet) lines.push(` ${d.snippet}`);
1081
- lines.push(` (entity_id: ${d.entity_id})`);
1082
- }
1083
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1084
- },
1085
- );
1086
617
 
1087
618
  // ===========================================================================
1088
- // TOOL: get_workspace_status — GET /v2/workspace/status
1089
- // The "one main call." Nous is operated by the agent, so the agent needs to
1090
- // know the state of the workspace: is it onboarded, is the GTM foundation built,
1091
- // which integrations are connected, is CRM sync configured, are events live —
1092
- // and what to set up next. Call this at the start of a session.
1093
- // ===========================================================================
1094
- server.tool(
1095
- "get_workspace_status",
1096
- "See the whole setup state of this workspace in one call, plus a ranked NEXT STEPS list (each step " +
1097
- "carries its own why/how). Nous is operated by you, the agent call this at the START of a session " +
1098
- "and walk the user top-down through the steps it returns; the server sequences them by current " +
1099
- "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 " +
1102
- "connect_integration, and CSV import is a user action in the app. (2) Respect the plan — never push " +
1103
- "a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
1104
- "whole list.",
1105
- {},
1106
- async () => {
1107
- const s = await get("/v2/workspace/status");
1108
- const setup = s.setup ?? {};
1109
- const lines = [];
619
+ // A closed deal as the agent may supply it. A bare domain is the original shape and still
620
+ // works; the object form carries the money and the date, which the forecast needs. Widened
621
+ // rather than replaced so nothing that already calls this breaks.
622
+ const CLOSED_DEAL = z.union([
623
+ z.string(),
624
+ z.object({
625
+ domain: z.string().describe("Company domain, e.g. 'acme.com' — no scheme."),
626
+ amount: z.number().optional().describe("Deal value in major units (48000, not 4800000)."),
627
+ currency: z.string().optional().describe("ISO code or symbol. Defaults to the workspace currency."),
628
+ 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."),
629
+ }),
630
+ ]);
1110
631
 
1111
- const ws = s.workspace ?? {};
1112
- lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
1113
- const pl = s.plan ?? {};
1114
- lines.push(`PLAN: ${pl.name || pl.id || "free"}${pl.crm_sync === false ? " (CRM sync not included — do not offer it)" : ""}`);
1115
- if (s.self_hosted) {
1116
- const e = s.env_integrations ?? {};
1117
- const mk = (b) => (b ? "✓ set" : "✗ NOT set");
1118
- lines.push("SELF-HOSTED — these channels are wired via nous.env (you can't set env vars; tell the operator to set + restart):");
1119
- lines.push(` LinkedIn/Unipile: ${mk(e.linkedin_unipile)} Email/Resend: ${mk(e.email_resend)} Gmail OAuth: ${mk(e.gmail_oauth)}`);
1120
- }
1121
- lines.push("");
1122
632
 
1123
- const mark = (b) => (b ? "✓" : "✗");
1124
- lines.push("SETUP:");
1125
- // The ICP first, because it IS the gate — a workspace without one is not set up, no
1126
- // matter how many integrations are green. If it's mirrored from a file in their repo,
1127
- // say so and say where: that file is the author, and editing anything else is a way of
1128
- // losing their work on the next sync.
1129
- const icp = setup.icp ?? {};
1130
- lines.push(
1131
- ` ${mark(icp.done)} ICP${icp.done
1132
- ? (icp.source === "claude_code" && icp.file_path
1133
- ? ` — mirrored from ${icp.file_path} (their repo is the author; edit the FILE, then sync)`
1134
- : " — authored in Nous")
1135
- : " — MISSING. The workspace is not set up until this exists. Scan their repo before you ask them anything."}`
1136
- );
1137
- lines.push(` ${mark(setup.onboarding?.done)} Profile${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
1138
- 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
- if (setup.icp_sync) {
1140
- 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" : ""}`);
1142
- }
1143
- const ints = setup.integrations?.connected ?? [];
1144
- lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
1145
- const crm = setup.crm_sync ?? {};
1146
- if (crm.available === false) {
1147
- lines.push(` – CRM sync (not on the ${pl.name || pl.id || "current"} plan)`);
1148
- } else {
1149
- lines.push(` ${mark(crm.configured)} CRM sync${crm.configured ? `: ${(crm.providers ?? []).map((p) => p.provider).join(", ")}` : ""}${crm.pending_hygiene_proposals ? ` · ${crm.pending_hygiene_proposals} hygiene proposal(s) to review` : ""}`);
1150
- }
1151
- lines.push(` ${mark(setup.enrichment?.connected)} Enrichment${setup.enrichment?.provider ? `: ${setup.enrichment.provider}` : ""}`);
1152
- lines.push(` ${mark((setup.webhooks?.count ?? 0) > 0 || (setup.triggers?.count ?? 0) > 0)} Events — ${setup.webhooks?.count ?? 0} webhook(s), ${setup.triggers?.count ?? 0} trigger(s)`);
1153
- const rec = setup.recommended ?? {};
1154
- lines.push("");
1155
- lines.push("RECOMMENDED CHANNELS (connect these first):");
1156
- lines.push(` ${mark(rec.email)} Email / Gmail ${mark(rec.linkedin)} LinkedIn ${mark(rec.meeting_notetaker)} Meeting note-taker`);
1157
- lines.push(` Records imported: ${setup.records?.count ?? 0}`);
1158
633
 
1159
- if (s.next_steps?.length) {
1160
- lines.push("");
1161
- lines.push("NEXT STEPS:");
1162
- for (const step of s.next_steps) {
1163
- lines.push(` • ${step.title}`);
1164
- if (step.why) lines.push(` why: ${step.why}`);
1165
- if (step.how) lines.push(` how: ${step.how}`);
1166
- }
1167
- } else {
1168
- lines.push("");
1169
- lines.push("Everything's set up. Nothing pending.");
1170
- }
1171
634
 
1172
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1173
- }
1174
- );
1175
635
 
1176
- // ===========================================================================
1177
- // TOOL: set_workspace_profile — POST /v2/workspace/onboarding
1178
- // Agent-driven onboarding. Instead of a human clicking through a wizard in the
1179
- // app, you collect the basics from the user in conversation and write them
1180
- // here. This is the first thing get_workspace_status asks for when a workspace
1181
- // is new.
1182
- // ===========================================================================
1183
- server.tool(
1184
- "set_workspace_profile",
1185
- "Onboard the workspace, or update its basic profile. Nous is set up by you, the agent, in " +
1186
- "conversation — not by the user clicking through a wizard. Ask the user for their company name, " +
1187
- "their website, whether they sell a SERVICE or SOFTWARE, and a sentence describing their ideal " +
1188
- "customer, then write them here. This seeds the GTM context and the ICP scoring model. Call " +
1189
- "get_workspace_status first to see what's already set; send only the fields you're setting or " +
1190
- "changing. " +
1191
- "IMPORTANT for the ICP: before asking the user to describe their ICP from scratch, if you're in " +
1192
- "Claude Code, look for an ICP they ALREADY wrote — folders like context/, .claude/, gtm/ and files " +
1193
- "named icp*, positioning*, pricing*, competitors*. If you find them, read them and call sync_icp to " +
1194
- "sync them (don't retype the ICP here); if none exists, scaffold a context/ folder (icp.md, " +
1195
- "positioning.md, pricing.md, market.md, competitors.md, gtm-motion.md) from the conversation + your " +
1196
- "site research, then sync_icp it — so their ICP lives in their repo. (Not in Claude Code? Capture a " +
1197
- "first cut in the `icp` field here instead.) " +
1198
- "After this, the next step is the context files: call sync_icp to sync them into the graph.",
1199
- {
1200
- name: z.string().optional().describe("The user's company / workspace name."),
1201
- website: z.string().optional().describe("The company website (used to seed the GTM context)."),
1202
- business_type: z.enum(["service", "software"]).optional()
1203
- .describe("Whether they sell a service or software — sets the CRM's buyer terminology and default signup stage."),
1204
- plan_model: z.enum(["free_plan", "free_trial", "both", "paid_only"]).optional()
1205
- .describe("For software only: how they package (free plan, free trial, both, or paid only)."),
1206
- default_signup_stage: z.string().optional()
1207
- .describe("The pipeline stage a brand-new signup lands in (e.g. 'Lead', 'Free User'). Defaults sensibly from business_type."),
1208
- icp: z.string().optional()
1209
- .describe("A sentence or two describing their ideal customer — seeds the ICP scoring model."),
1210
- },
1211
- async ({ name, website, business_type, plan_model, default_signup_stage, icp }) => {
1212
- const r = await post("/v2/workspace/onboarding", { name, website, business_type, plan_model, default_signup_stage, icp });
1213
- const w = r.workspace ?? {};
1214
- const set = [
1215
- w.name && `name=${w.name}`,
1216
- w.website && `site=${w.website}`,
1217
- w.business_type && `type=${w.business_type}`,
1218
- icp && "ICP recorded",
1219
- ].filter(Boolean);
1220
- return { content: [{ type: "text", text:
1221
- `Workspace profile saved.${set.length ? ` ${set.join(" · ")}.` : ""}\n` +
1222
- `Next: call get_workspace_status to see what to set up next (usually syncing the ICP/context files with sync_icp).` }] };
1223
- }
1224
- );
1225
636
 
1226
- // ===========================================================================
1227
- // TOOL: build_icp_model — POST /v2/workspace/scoring-model
1228
- // The second half of building the GTM foundation. The agent syncs the GTM context
1229
- // from the user's files with sync_icp, then calls this to turn it into a weighted
1230
- // ICP scoring model. After this, accounts get scored for fit and
1231
- // get_workspace_status shows the foundation as done.
1232
- // ===========================================================================
1233
- server.tool(
1234
- "build_icp_model",
1235
- "Build (or rebuild) the user's ICP scoring model from their synced GTM context. This is " +
1236
- "the second half of setting up the GTM foundation: first sync the user's ICP/positioning/pricing " +
1237
- "files with sync_icp, then call this to translate that context into a weighted set of scoring " +
1238
- "signals so accounts get scored for fit. (sync_icp usually builds the model on first sync, so you " +
1239
- "often won't need this directly.) If a model already exists it is left alone unless you " +
1240
- "pass force:true (use that when the context files have changed and the model should be rebuilt). If " +
1241
- "it reports no GTM context yet, sync the user's context files with sync_icp first, then call this again. " +
1242
- "STRONGER than this tool: if the user can name a few closed-WON and closed-LOST customer domains, " +
1243
- "call train_icp_model instead (or as well) — it trains the model on real outcomes via " +
1244
- "contrastive lift, which beats a model inferred from a description.",
1245
- {
1246
- force: z.boolean().optional()
1247
- .describe("Rebuild the model even if one already exists — use when the GTM context has changed."),
1248
- },
1249
- async ({ force }) => {
1250
- try {
1251
- const r = await post("/v2/workspace/scoring-model", { force: force === true });
1252
- const signals = r.signals ?? [];
1253
- const lines = [`Built the ICP scoring model — ${signals.length} signal${signals.length === 1 ? "" : "s"}:`];
1254
- for (const s of signals) lines.push(` • ${s.label ?? s.key} (weight ${s.weight})`);
1255
- lines.push("", "Accounts will now be scored for fit. Check it on the GTM Context page.");
1256
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1257
- } catch (e) {
1258
- // Surface the actionable cases (no context yet / model already exists) as
1259
- // guidance rather than a raw error, so the agent knows what to do next.
1260
- const msg = String(e?.message ?? e);
1261
- if (msg.includes("no_gtm_context")) {
1262
- return { content: [{ type: "text", text:
1263
- "No GTM context yet. Sync the user's ICP/context files with sync_icp first (or scaffold context/icp.md, then sync_icp), then build the model." }] };
1264
- }
1265
- if (msg.includes("model_exists")) {
1266
- return { content: [{ type: "text", text:
1267
- "A scoring model already exists. Call build_icp_model again with force:true to rebuild it from the current GTM context." }] };
1268
- }
1269
- throw e;
1270
- }
1271
- }
1272
- );
1273
637
 
1274
- // ===========================================================================
1275
- // TOOL: train_icp_model — POST /v2/workspace/closed-deals
1276
- // Build the ICP model from REAL outcomes via contrastive lift (won vs lost).
1277
- // ===========================================================================
1278
- server.tool(
1279
- "train_icp_model",
1280
- "Build (or sharpen) the ICP scoring model from the user's REAL closed deals. Pass closed-WON " +
1281
- "customer domains and closed-LOST domains; Nous enriches each, links the contacts you already " +
1282
- "have there, and runs contrastive lift (what's true of winners but not losers) to discover the " +
1283
- "signals that actually predict revenue — then re-scores open accounts. This is the strongest way " +
1284
- "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.",
1287
- {
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']."),
1290
- },
1291
- async ({ won, lost }) => {
1292
- try {
1293
- const r = await post("/v2/workspace/closed-deals", { won: won ?? [], lost: lost ?? [] });
1294
- const disc = r.discovered ?? [];
1295
- const lines = [
1296
- `Learned from ${r.won ?? 0} won + ${r.lost ?? 0} lost deal${(r.won ?? 0) + (r.lost ?? 0) === 1 ? "" : "s"} ` +
1297
- `(${r.enriched ?? 0} enriched, ${r.mode === "winners" ? "winner-signal" : "contrastive-lift"} mode).`,
1298
- ];
1299
- if (disc.length) {
1300
- lines.push("", "Signals discovered:");
1301
- for (const d of disc) lines.push(` • ${d.label} (weight ${d.weight})${d.note ? ` — ${d.note}` : ""}`);
1302
- }
1303
- lines.push("", "The model updated and open accounts were re-scored. See the GTM Context page.");
1304
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1305
- } catch (e) {
1306
- const msg = String(e?.message ?? e);
1307
- if (msg.includes("need_more_deals")) {
1308
- return { content: [{ type: "text", text: "Give me at least one closed-won or closed-lost domain to learn from." }] };
1309
- }
1310
- throw e;
1311
- }
1312
- }
1313
- );
1314
638
 
1315
- // ===========================================================================
1316
- // TOOL: sync_icp — POST /v2/workspace/icp/import
1317
- // The file→Nous half of the ICP symbiosis. In Claude Code the user often
1318
- // already keeps their ICP/positioning as markdown (context/icp.md, etc.). Don't
1319
- // 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
1322
- // truth for the prose; Nous owns the learned scoring half.
1323
- // ===========================================================================
1324
- server.tool(
1325
- "sync_icp",
1326
- "Sync the user's EXISTING ICP/positioning files into Nous, instead of making them re-author their " +
1327
- "ICP in a second place. CLAUDE CODE flow: when onboarding (or whenever their ICP files change), look " +
1328
- "in the project for an existing GTM setup — folders like context/, .claude/, gtm/, and files named " +
1329
- "icp*, positioning*, pricing*, competitors*, messaging*, market*. READ the ones you find with your " +
1330
- "own file tools, then call this with each file's content mapped to a section, AND its path in " +
1331
- "`source_path`. MAP GRANULARLY: map each FILE to the single section it best fits (icp.md -> ICP, " +
1332
- "positioning.md -> Positioning, pricing.md -> Pricing, competitors.md -> Competitors, market.md -> " +
1333
- "Market, messaging.md -> Notes) — one entry per file, do NOT dump several files' content into ICP. " +
1334
- "If one file holds several sections under headers, split it by header into multiple entries. " +
1335
- "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. " +
1337
- "INCLUDE EXCLUSIONS: if the ICP names who they will NOT work with (e.g. 'not cold-calling " +
1338
- "agencies', 'no pure branding/messaging shops'), keep that text IN the ICP section — Nous turns " +
1339
- "each stated exclusion into a hard disqualifier that caps those accounts below Not-ICP, even when " +
1340
- "they also match the firmographics. So a 'Not a fit' list in icp.md actively lowers their score. " +
1341
- "IF NO ICP FILES EXIST: don't invent context in Nous. Offer to SCAFFOLD a context/ folder in their " +
1342
- "repo — context/icp.md, positioning.md, pricing.md, market.md, competitors.md, gtm-motion.md — " +
1343
- "filled from what the user tells you plus your own research of their website (write them with your " +
1344
- "file tools), then call this on those files — so their GTM context lives in their repo where they'll " +
1345
- "keep editing it. At minimum create context/icp.md if that's all they'll give you. " +
1346
- "MANDATORY RE-SYNC: whenever you (or the user) edit the ICP/context file — add or change an exclusion, " +
1347
- "reword the ICP, retarget — you MUST call sync_icp again in the SAME turn. The edit does NOT change the " +
1348
- "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).",
1350
- {
1351
- sections: z.array(z.object({
1352
- section: z.enum(["ICP", "Market", "Product", "Pricing", "Competitors", "Positioning", "GTM Motion", "Notes"])
1353
- .describe("Which GTM context section this file/content maps to."),
1354
- content: z.string().describe("The section's content, read from the file (trimmed prose, not the whole repo)."),
1355
- source_path: z.string().optional()
1356
- .describe("The file this came from, relative to the project root, e.g. 'context/icp.md'. Required on the ICP section so the learned model can be written back."),
1357
- })).describe("One entry per ICP/positioning file (or section) you read."),
1358
- },
1359
- async ({ sections }) => {
1360
- try {
1361
- const r = await post("/v2/workspace/icp/import", { sections });
1362
- const imp = r.imported ?? [];
1363
- const lines = [
1364
- `Synced ${imp.length} section${imp.length === 1 ? "" : "s"} from the user's files:`,
1365
- ...imp.map((s) => ` • ${s.section}${s.source_path ? ` ← ${s.source_path}` : ""}`),
1366
- ];
1367
- if (r.skipped?.length) lines.push("", `Skipped (unknown/empty): ${r.skipped.join(", ")}`);
1368
- // Section-check nudges from the server — the ICP file synced but is missing
1369
- // canonical sections (buyer, fit, triggers, …). Surface them so the agent
1370
- // rounds the file out and re-syncs, instead of the gaps passing silently.
1371
- for (const w of (r.warnings ?? [])) {
1372
- lines.push("", `⚠ ${w.message}`);
1373
- }
1374
- const sig = r.signals ?? [];
1375
- if (r.model_status === "created" && sig.length) {
1376
- 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.");
1378
- } else if (r.model_status === "no_icp_memory") {
1379
- lines.push("", "Synced, but there wasn't enough ICP content to build a scoring model — make sure the ICP section has real content.");
1380
- } else {
1381
- lines.push("", "Context synced. Call export_icp_model when you want to write the learned model back into their ICP file.");
1382
- }
1383
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
1384
- } catch (e) {
1385
- const msg = String(e?.message ?? e);
1386
- if (msg.includes("no_sections") || msg.includes("no_valid_sections")) {
1387
- return { content: [{ type: "text", text:
1388
- "Nothing to sync. Read the user's ICP/positioning file(s) first and pass each as a section " +
1389
- "(ICP, Positioning, Pricing, …) with its source_path. If they have no such file, offer to create context/icp.md." }] };
1390
- }
1391
- throw e;
1392
- }
1393
- }
1394
- );
1395
639
 
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
640
 
1439
- // ===========================================================================
1440
- // TOOL: connect_integration — POST /v2/workspace/integrations
1441
- // The agent connects a KEY-BASED integration for the user (no clicking through
1442
- // the Integrations page). OAuth providers still need a browser, so this is
1443
- // limited to providers that authenticate with an API key/token.
1444
- // ===========================================================================
1445
- server.tool(
1446
- "connect_integration",
1447
- "Connect a key-based integration for the user — an enrichment, CRM, or sequencer provider that " +
1448
- "authenticates with an API key or token (e.g. Apollo, Prospeo, Instantly, HubSpot private-app " +
1449
- "token, Pipedrive, Attio, Smartlead, HeyReach). Ask the user for the provider's API key, then " +
1450
- "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.",
1453
- {
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: '...' }."),
1456
- name: z.string().optional().describe("Optional label for the connection."),
1457
- },
1458
- async ({ provider, credentials, name }) => {
1459
- try {
1460
- const r = await post("/v2/workspace/integrations", { provider, credentials, name });
1461
- return { content: [{ type: "text", text: `Connected ${r.connection?.provider ?? provider}.${r.message ? ` ${r.message}` : ""}` }] };
1462
- } catch (e) {
1463
- const msg = String(e?.message ?? e);
1464
- 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.` }] };
1466
- }
1467
- if (msg.includes("invalid_credentials")) {
1468
- return { content: [{ type: "text", text: `Those credentials didn't verify for ${provider}. Ask the user to double-check the key and try again.` }] };
1469
- }
1470
- if (msg.includes("unknown_provider")) {
1471
- return { content: [{ type: "text", text: `No provider named "${provider}". Ask the user which tool they mean.` }] };
1472
- }
1473
- throw e;
1474
- }
1475
- }
1476
- );
1477
641
 
1478
- // ===========================================================================
1479
- // TOOL: configure_crm_sync — POST /v2/workspace/crm-sync
1480
- // The agent sets the CRM sync rules — the same options as the CRM Sync page.
1481
- // The CRM must already be connected (OAuth connect stays a human step).
1482
- // ===========================================================================
1483
- server.tool(
1484
- "configure_crm_sync",
1485
- "(Nous Cloud only) Configure how Nous keeps a connected CRM in sync — the same settings as the CRM Sync page. The " +
1486
- "CRM must already be connected (HubSpot/Pipedrive/Attio). Set any of: auto-sync (daily pull), " +
1487
- "push of touchpoints, the create policy (when a new record is auto-created and the ICP-fit " +
1488
- "threshold), and the hygiene cadence. Only send the fields you want to change. If it reports the " +
1489
- "CRM isn't connected, tell the user to connect it on the Integrations page first.",
1490
- {
1491
- provider: z.enum(["hubspot", "pipedrive", "attio"]).describe("Which connected CRM to configure."),
1492
- autoSync: z.boolean().optional().describe("Pull contacts/companies/deals daily."),
1493
- pushActivities: z.boolean().optional().describe("Push touchpoints (meetings, replies, proposals) back to the CRM."),
1494
- createInCrm: z.boolean().optional().describe("Auto-create new records in the CRM when they earn it."),
1495
- createTrigger: z.enum(["any_reply_or_meeting", "positive_reply_or_meeting", "meeting_only", "interested_stage"]).optional()
1496
- .describe("What earns a new record."),
1497
- createRequireIcpFit: z.boolean().optional().describe("Require an ICP-fit score before creating a record."),
1498
- createIcpThreshold: z.number().optional().describe("Minimum ICP-fit score to create (0-100)."),
1499
- hygieneEnabled: z.boolean().optional().describe("Run scheduled hygiene reconciliation."),
1500
- hygieneCadence: z.enum(["weekly", "monthly"]).optional().describe("How often hygiene runs."),
1501
- },
1502
- async (args) => {
1503
- try {
1504
- const r = await post("/v2/workspace/crm-sync", args);
1505
- const c = r.config ?? {};
1506
- return { content: [{ type: "text", text:
1507
- `CRM sync configured for ${args.provider}. auto-sync ${c.auto_sync ? "on" : "off"}, ` +
1508
- `create ${c.create_in_crm ? `on (${c.create_trigger}${c.create_require_icp_fit ? `, ICP ≥ ${c.create_icp_threshold}` : ""})` : "off"}, ` +
1509
- `hygiene ${c.hygiene_enabled ? c.hygiene_cadence : "off"}.` }] };
1510
- } catch (e) {
1511
- const msg = String(e?.message ?? e);
1512
- if (msg.includes("crm_not_connected")) {
1513
- return { content: [{ type: "text", text: `${args.provider} isn't connected yet. Tell the user to connect it on the Integrations page, then configure sync.` }] };
1514
- }
1515
- throw e;
1516
- }
1517
- }
1518
- );
1519
642
 
1520
- // ===========================================================================
1521
- // TOOL: sync_crm_now — POST /v2/workspace/crm-sync-now
1522
- // Run an immediate incremental CRM pull right now, instead of waiting for the
1523
- // daily auto-sync cron — e.g. straight after configure_crm_sync, or whenever
1524
- // the user wants the latest. Same engine the scheduled sync uses.
1525
- // ===========================================================================
1526
- server.tool(
1527
- "sync_crm_now",
1528
- "(Nous Cloud only) Pull the latest from a connected CRM (HubSpot/Pipedrive/Attio) RIGHT NOW, instead of waiting for " +
1529
- "the daily auto-sync. Use it just after configure_crm_sync to seed the data, or whenever the user " +
1530
- "wants an immediate refresh. Incremental by default (only what changed since the last pull); pass " +
1531
- "full:true to re-fetch everything. The CRM must already be connected and sync configured — if not, " +
1532
- "it'll tell you to connect/configure first.",
1533
- {
1534
- provider: z.enum(["hubspot", "pipedrive", "attio"]).optional().describe("Which connected CRM to pull from (default hubspot)."),
1535
- full: z.boolean().optional().describe("true = re-fetch everything; default = incremental since the last sync."),
1536
- },
1537
- async ({ provider, full }) => {
1538
- try {
1539
- const r = await post("/v2/workspace/crm-sync-now", { provider: provider || "hubspot", full: full === true });
1540
- const errs = (r.errors && r.errors.length) ? ` · ${r.errors.length} error(s)` : "";
1541
- return { content: [{ type: "text", text:
1542
- `Pulled from ${r.provider}: ${r.fetched ?? 0} records — ${r.created ?? 0} new, ${r.updated ?? 0} updated${errs}.` }] };
1543
- } catch (e) {
1544
- const msg = String(e?.message ?? e);
1545
- if (/sync_not_configured/.test(msg)) return { content: [{ type: "text", text: `Sync isn't configured for that CRM yet — call configure_crm_sync first.` }] };
1546
- if (/crm_not_connected/.test(msg)) return { content: [{ type: "text", text: `That CRM isn't connected. Tell the user to connect it on the Integrations page, then try again.` }] };
1547
- if (/salesforce_not_yet_supported/.test(msg)) return { content: [{ type: "text", text: `Salesforce pull isn't supported yet — only HubSpot, Pipedrive, and Attio.` }] };
1548
- return { content: [{ type: "text", text: `Couldn't sync: ${msg}` }] };
1549
- }
1550
- }
1551
- );
1552
-
1553
- // ===========================================================================
1554
- // TOOL: scrape_engagers
1555
- // On-demand LinkedIn engager scrape — mine who commented/reacted on the
1556
- // workspace's own recent posts into the native "LinkedIn Engagers" list, NOW,
1557
- // instead of waiting for the weekly cron. Backfill a wider window with `days`.
1558
- // ===========================================================================
1559
- server.tool(
1560
- "scrape_engagers",
1561
- "Scrape the people who commented or reacted on YOUR OWN recent LinkedIn posts into the native " +
1562
- "\"LinkedIn Engagers\" lead list — right now, instead of waiting for the weekly auto-run. Each " +
1563
- "engager is saved with the engagement captured (the actual comment text for comments, the " +
1564
- "reaction for likes) on their timeline. Use when the user says \"scrape engagers\", \"who " +
1565
- "engaged with my last post\", or \"backfill my engagers for the last N months\". `days` sets the " +
1566
- "look-back window (default 7, since the weekly run already covers the recent past; pass a larger " +
1567
- "value like 60 to backfill). Runs on the workspace's OWN Apify key (bring-your-own-key) — if none " +
1568
- "is connected it says so; tell the user to add an Apify key in Integrations. The scrape runs in " +
1569
- "the background (within a minute); the new engagers then appear in the list.",
1570
- {
1571
- days: z.number().int().min(1).max(120).optional().describe("Look-back window in days. Default 7. Use a larger value (e.g. 60) to backfill a gap since the last scrape."),
1572
- },
1573
- async ({ days }) => {
1574
- try {
1575
- const r = await post("/api/linkedin/engagement/scrape", { days });
1576
- const lastLine = r.last_scraped_at
1577
- ? `Last scraped ${relAge(r.last_scraped_at)}.`
1578
- : "First scrape for this workspace.";
1579
- return { content: [{ type: "text", text:
1580
- `Engager scrape queued — mining the last ${r.days} day(s) across ${r.accounts} connected ` +
1581
- `LinkedIn account${r.accounts === 1 ? "" : "s"}. ${lastLine} It runs in the background; ` +
1582
- `new engagers land in the "LinkedIn Engagers" list within a minute or two.` }] };
1583
- } catch (e) {
1584
- const msg = /apify_not_connected/.test(e.message)
1585
- ? "Engager scraping is bring-your-own-key. Tell the user to add their own Apify key in Integrations, then try again."
1586
- : /linkedin_not_connected/.test(e.message)
1587
- ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1588
- : /needs_plan/.test(e.message)
1589
- ? "LinkedIn engager scraping is on the Pro plan and up. Tell the user to upgrade to use it."
1590
- : `Couldn't start the scrape: ${e.message}`;
1591
- return { content: [{ type: "text", text: msg }] };
1592
- }
1593
- }
1594
- );
1595
-
1596
- // ===========================================================================
1597
- // 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
1600
- // workspace from the API key and runs on the workspace's connected LinkedIn
1601
- // account. POST /api/linkedin/send-message (verifyAuthEither).
1602
- // ===========================================================================
1603
- server.tool(
1604
- "send_linkedin_message",
1605
- "SEND a LinkedIn direct message for real, right now, from the workspace's connected LinkedIn " +
1606
- "account. This is an OUTWARD ACTION, not a draft — it reaches the person the moment you call it, so " +
1607
- "only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
1608
- "profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
1609
- "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 " +
1612
- "LinkedIn account (Integrations); it says so if none is connected.",
1613
- {
1614
- text: z.string().min(1).describe("The message body to send."),
1615
- 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)."),
1616
- linkedin_member_id: z.string().optional().describe("The recipient's LinkedIn member id, if you have it instead of a profile URL."),
1617
- chat_id: z.string().optional().describe("An existing conversation's chat_id — pass it to reply in-thread instead of opening a new chat."),
1618
- },
1619
- async ({ text, linkedin_url, linkedin_member_id, chat_id }) => {
1620
- if (!linkedin_url && !linkedin_member_id && !chat_id) {
1621
- return { content: [{ type: "text", text:
1622
- "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." }] };
1623
- }
1624
- try {
1625
- const r = await post("/api/linkedin/send-message", { text, linkedin_url, linkedin_member_id, chat_id });
1626
- return { content: [{ type: "text", text:
1627
- `Message sent over LinkedIn.${r.chat_id ? ` Thread chat_id: ${r.chat_id} (reuse it to reply in-thread).` : ""}` }] };
1628
- } catch (e) {
1629
- const msg =
1630
- /linkedin_not_connected/.test(e.message)
1631
- ? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
1632
- : /auth_required/.test(e.message)
1633
- ? "This API key isn't scoped to a workspace, or LinkedIn isn't connected."
1634
- : /missing_params/.test(e.message)
1635
- ? "Couldn't tell who to message — provide linkedin_url, linkedin_member_id, or chat_id."
1636
- : `Couldn't send the message: ${e.message}`;
1637
- return { content: [{ type: "text", text: msg }] };
1638
- }
1639
- }
1640
- );
1641
-
1642
- server.tool(
1643
- "draft_email",
1644
- "DRAFT a follow-up email for the operator to review and SEND themselves — you draft, they send. " +
1645
- "This does NOT send: it puts a card in front of the operator with To / Subject / Body and a Send " +
1646
- "button; the email goes out from THEIR OWN Gmail, in their name, only when they click send. Use it " +
1647
- "whenever the ask is to email, follow up by email, send a recap, or reply by email. Identify the " +
1648
- "recipient with `focus` (their email, LinkedIn URL, entity id, or name); the recipient address is " +
1649
- "read off the record unless you pass `to`. Write a real `subject` and `body` in the operator's " +
1650
- "voice — real paragraphs, no placeholders or [brackets] — and give the `rationale`, the one line " +
1651
- "they judge the draft against. For a LinkedIn-only contact with no email, use send_linkedin_message.",
1652
- {
1653
- focus: z.string().describe("The recipient — email, LinkedIn URL, entity UUID, or name."),
1654
- subject: z.string().describe("The email subject line."),
1655
- body: z.string().min(1).describe("The email body, in the operator's voice. Real paragraphs, no placeholders."),
1656
- to: z.string().optional().describe("Override the recipient email address (else the entity's best email on record is used)."),
1657
- rationale: z.string().optional().describe("One line on what the draft is based on — shown to the operator on the card."),
1658
- },
1659
- async ({ focus, subject, body, to, rationale }) => {
1660
- try {
1661
- const r = await post("/v2/drafts/email", { focus, subject, body, to, rationale });
1662
- if (r.status === "ambiguous") {
1663
- const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
1664
- return { content: [{ type: "text", text: `"${focus}" matches several people. Re-call draft_email with one of these entity ids as focus:\n${opts}` }] };
1665
- }
1666
- return { content: [{ type: "text", text:
1667
- `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.` }] };
1668
- } catch (e) {
1669
- const msg = /no_email/.test(e.message)
1670
- ? "No email on file for that person. Ask the operator for the address (pass it as `to`), or draft a LinkedIn message instead."
1671
- : /entity_not_found/.test(e.message)
1672
- ? "Couldn't find that recipient on the record — check the focus identifier."
1673
- : `Couldn't create the draft: ${e.message}`;
1674
- return { content: [{ type: "text", text: msg }] };
1675
- }
1676
- }
1677
- );
1678
-
1679
- // ===========================================================================
1680
- // TOOLS: the CORRECTION layer — unsay something recorded by mistake. `record` and
1681
- // `save_note` are how you write; these are how you take it back. Both heal the
1682
- // derived layer: retracting an observation re-derives the claim from what remains,
1683
- // deleting a note drops it from search/context. DELETE /v2/observations|notes/:id.
1684
- // ===========================================================================
1685
- server.tool(
1686
- "retract_observation",
1687
- "RETRACT an observation you recorded by mistake, and heal the record. Pass the observation's " +
1688
- "`id` (returned by `record`). Nous deletes it and re-derives the affected fact from the " +
1689
- "observations that remain — so a wrong value you observed is un-observed and the claim reverts as " +
1690
- "if it had never happened; if it was the only observation for that fact, the fact is invalidated. " +
1691
- "Use this when you recorded the wrong thing (wrong value, wrong person, a test), NOT to represent a " +
1692
- "real change over time — a genuine change is a NEW `record`, which supersedes by recency.",
1693
- {
1694
- id: z.string().describe("The observation id to retract (from a prior `record` result)."),
1695
- },
1696
- async ({ id }) => {
1697
- try {
1698
- const r = await del(`/v2/observations/${encodeURIComponent(id)}`);
1699
- return { content: [{ type: "text", text:
1700
- `Observation retracted. The claim for ${r.property} was ${r.claim === "invalidated" ? "invalidated (no observations left)" : "re-derived from the remaining observations"}.` }] };
1701
- } catch (e) {
1702
- const msg = /observation_not_found/.test(e.message)
1703
- ? "No observation with that id in this workspace — check the id from the record result."
1704
- : `Couldn't retract the observation: ${e.message}`;
1705
- return { content: [{ type: "text", text: msg }] };
1706
- }
1707
- }
1708
- );
1709
-
1710
- server.tool(
1711
- "delete_note",
1712
- "DELETE a note saved by mistake. Pass the note's `id` (returned by `save_note`). It's removed from " +
1713
- "search and context immediately; the timeline stays reconstructable. Only touches notes — it will " +
1714
- "refuse an id that isn't a note. Use this for a note saved in error; to CHANGE a note, save a new " +
1715
- "one instead.",
1716
- {
1717
- id: z.string().describe("The note id to delete (from a prior `save_note` result)."),
1718
- },
1719
- async ({ id }) => {
1720
- try {
1721
- const r = await del(`/v2/notes/${encodeURIComponent(id)}`);
1722
- return { content: [{ type: "text", text:
1723
- r.status === "already_deleted" ? "That note was already deleted." : "Note deleted — it's out of search and context now." }] };
1724
- } catch (e) {
1725
- const msg = /note_not_found/.test(e.message)
1726
- ? "No note with that id in this workspace — check the id from the save_note result."
1727
- : /not_a_note/.test(e.message)
1728
- ? "That id isn't a note, so it can't be deleted here. Only save_note notes can be deleted this way."
1729
- : `Couldn't delete the note: ${e.message}`;
1730
- return { content: [{ type: "text", text: msg }] };
1731
- }
1732
- }
1733
- );
1734
643
 
1735
644
  // ===========================================================================
1736
- // TOOLS: list + introspect enumerate what's on the workspace, and who you are.
1737
- // GET /v2/notes · /v2/workspace/integrations · /v2/workspace/members.
645
+ // TOOL: whoami GET /v2/workspace/whoami
646
+ // The identity primitive: who this key acts AS, their scope, and their GTM
647
+ // role(s). Every governance/permission decision hangs off knowing the actor.
1738
648
  // ===========================================================================
1739
- server.tool(
1740
- "list_notes",
1741
- "List saved notes newest-first (chronological), optionally scoped to one person/company with " +
1742
- "`focus`. This is the LIST companion to search_notes (which is semantic): use it for \"show me the " +
1743
- "notes on X\", or to enumerate notes and get their ids — e.g. to pick one to delete_note. Returns " +
1744
- "id, type, title, date and a snippet for each.",
1745
- {
1746
- focus: z.string().optional().describe("Restrict to one person/company (email, LinkedIn URL, entity UUID, or domain). Omit for the whole workspace."),
1747
- limit: z.number().int().min(1).max(100).optional().describe("Max notes to return (default 20)."),
1748
- },
1749
- async ({ focus, limit }) => {
1750
- const r = await get("/v2/notes", { ...(focus ? { focus } : {}), ...(limit ? { limit } : {}) });
1751
- if (r.status === "ambiguous") {
1752
- const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
1753
- return { content: [{ type: "text", text: `"${focus}" matches several — re-call list_notes with one of these entity ids as focus:\n${opts}` }] };
1754
- }
1755
- if (!r.notes?.length) return { content: [{ type: "text", text: focus ? "No notes on that account." : "No notes saved yet." }] };
1756
- const lines = r.notes.map(n => ` • ${n.title || n.type}${n.date ? ` (${n.date})` : ""} [${n.id}]\n ${n.snippet}`).join("\n");
1757
- return { content: [{ type: "text", text: `${r.notes.length} note(s)${r.has_more ? " (more available — raise limit)" : ""}:\n${lines}` }] };
1758
- }
1759
- );
1760
-
1761
- server.tool(
1762
- "list_integrations",
1763
- "List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +
1764
- "— what's wired in and whether it's verified. Use before telling the user to connect something, or " +
1765
- "to answer \"what's connected here?\".",
1766
- {},
1767
- async () => {
1768
- const r = await get("/v2/workspace/integrations");
1769
- if (!r.integrations?.length) return { content: [{ type: "text", text: "No integrations connected yet." }] };
1770
- const lines = r.integrations.map(i => ` • ${i.display_name}${i.category ? ` (${i.category})` : ""}${i.verified ? "" : " — not verified"}`).join("\n");
1771
- return { content: [{ type: "text", text: `Connected integrations:\n${lines}` }] };
1772
- }
1773
- );
1774
-
1775
- server.tool(
649
+ tool(
1776
650
  "whoami",
1777
- "Report who this API key acts AS and who else is on the workspace the agent's own identity in " +
1778
- "Nous. Returns your scope (a MEMBER key sees only that member's private content plus the shared " +
1779
- "graph; an ADMIN key sees all raw content) and the team roster with names and roles. Use it to " +
1780
- "understand whose view you have and to reference teammates. Emails show only for an admin key.",
1781
- {},
1782
- async () => {
1783
- const r = await get("/v2/workspace/members");
1784
- const scope = r.you?.scope === "admin"
1785
- ? "an ADMIN key — you see all raw content on this workspace"
1786
- : "a MEMBER key — you see only your own private content plus the shared graph";
1787
- const roster = (r.members || [])
1788
- .map(m => ` • ${m.name || "(unnamed)"} — ${m.role}${m.you ? " (you)" : ""}${m.email ? ` · ${m.email}` : ""}`)
1789
- .join("\n");
1790
- return { content: [{ type: "text", text: `You are ${scope}.\n\nWorkspace members (${r.count ?? 0}):\n${roster || " (none)"}` }] };
1791
- }
1792
- );
1793
-
1794
- // ===========================================================================
1795
- // TOOL: get_routing_preferences
1796
- // The routing preferences that make THIS agent default to Nous for GTM. The
1797
- // agent applies them itself in Claude Code (write to CLAUDE.md); for claude.ai
1798
- // the user pastes them into Org preferences. Not applicable to Codex/others.
1799
- // ===========================================================================
1800
- server.tool(
1801
- "get_routing_preferences",
1802
- "Get the routing preferences that make this agent treat Nous as the DEFAULT for GTM — so it " +
1803
- "reaches for get_context / get_account instead of raw CRM or HubSpot / Salesforce / Gong / " +
1804
- "Granola when someone forgets to say \"Nous\". This is an OPTIONAL onboarding step, and " +
1805
- "CLAUDE CODE-specific. If you are running in Claude Code, set it up for the user: ask whether to " +
1806
- "apply it to this project (write the returned text to ./CLAUDE.md) or to all projects " +
1807
- "(~/.claude/CLAUDE.md), then write it there. The user can also paste it into claude.ai → Settings " +
1808
- "→ Organization preferences (Team/Enterprise) or Personal preferences (Pro). If you are NOT Claude " +
1809
- "Code (Codex, Cursor, n8n, …), this does not apply — skip it.",
651
+ "Report who this API key acts AS: the workspace, the person, their SCOPE (admin sees the whole " +
652
+ "workspace; member sees only their own book plus the shared graph), their GTM ROLE(S) (e.g. AE, " +
653
+ "SDR, founder a person can hold several), and the key's capability (read/write vs read-only). " +
654
+ "Call it to confirm the plugin is wired to the right workspace and identity, and to scope " +
655
+ "role-specific work to the right person — a member gets their own view, an admin the whole team's.",
1810
656
  {},
1811
657
  async () => {
658
+ const r = await get("/v2/workspace/whoami");
659
+ const w = r.workspace || {};
660
+ const y = r.you || {};
661
+ const who = y.name || (y.user_id ? "(unnamed member)" : "a shared workspace key (no person)");
662
+ const scope = y.scope === "admin"
663
+ ? "ADMIN — you see the whole workspace"
664
+ : "MEMBER — you see only your own book plus the shared graph";
665
+ const roles = Array.isArray(y.roles) && y.roles.length ? y.roles.join(", ") : "none set";
666
+ const cap = y.capability === "read_only" ? "read-only" : "read/write";
1812
667
  return { content: [{ type: "text", text:
1813
- `Routing preferences (write to the user's CLAUDE.md in Claude Code, or have them paste into ` +
1814
- `claude.ai → Settings → Organization/Personal preferences):\n\n${ROUTING_PREFERENCES}` }] };
668
+ `You are acting as ${who} on ${w.name || "this workspace"}.\n` +
669
+ `Scope: ${scope}.\n` +
670
+ `Role(s): ${roles}.\n` +
671
+ `Capability: ${cap}.` }] };
1815
672
  }
1816
673
  );
1817
674