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