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