@opennous/mcp 0.44.2 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/http.js +1 -1
- package/src/server.js +333 -416
package/package.json
CHANGED
package/src/http.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Nous MCP Server — hosted, multi-tenant HTTP entrypoint (mcp.opennous.cloud).
|
|
5
5
|
*
|
|
6
|
-
* Serves the same
|
|
6
|
+
* Serves the same tool set (server.js) over the MCP Streamable HTTP transport,
|
|
7
7
|
* so cloud clients that cannot launch a local process — n8n cloud above all —
|
|
8
8
|
* can connect by pasting one URL plus their workspace API key.
|
|
9
9
|
*
|
package/src/server.js
CHANGED
|
@@ -10,40 +10,23 @@
|
|
|
10
10
|
* sees raw rows — it gets engineered, epistemics-tagged context. It never
|
|
11
11
|
* "updates" — it records observations; Nous derives.
|
|
12
12
|
*
|
|
13
|
-
* Tools:
|
|
14
|
-
* get_context
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* record
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* search_notes — semantic search over saved notes & documents
|
|
24
|
-
* get_workspace_status — what's set up in this workspace + a ranked next_steps list (call first)
|
|
25
|
-
* set_workspace_profile— agent-driven onboarding: set the workspace's name, site, type, ICP
|
|
26
|
-
* build_icp_model — build/rebuild the ICP scoring model from the recorded GTM context
|
|
27
|
-
* train_icp_model — build the ICP model from real closed-won/lost deals (contrastive lift)
|
|
28
|
-
* sync_icp — sync the user's EXISTING ICP/positioning files into Nous (file → graph)
|
|
29
|
-
* export_icp_model — get the learned ICP model as a block to write back into their ICP file (graph → file)
|
|
30
|
-
* connect_integration — connect a key-based integration (Apollo, Prospeo, HubSpot, …)
|
|
31
|
-
* configure_crm_sync — set CRM sync rules (auto-sync, create policy, hygiene cadence)
|
|
32
|
-
* sync_crm_now — run an immediate incremental/full CRM pull (don't wait for the daily cron)
|
|
33
|
-
* set_trigger — create an outbound event trigger (webhook); list_triggers reads them
|
|
34
|
-
* list_triggers — list the workspace's event triggers + available events
|
|
35
|
-
* get_routing_preferences — Claude Code routing prefs to default GTM to Nous (write to CLAUDE.md)
|
|
36
|
-
* lead_list_operations — the operations trail of a lead list (imports/enrich/push/replies), filterable
|
|
37
|
-
* get_coverage — pre-spend coverage: exact per-lead check (identifiers) or attribute estimate (title/keyword)
|
|
38
|
-
* enrich_leads — find missing emails for a lead list (two-step: dry-run cost preview, then confirm)
|
|
39
|
-
* verify_leads — validate email deliverability for a lead list (two-step preview, then confirm)
|
|
13
|
+
* Tools, by group (the authoritative live catalog is `node scripts/list-tools.mjs`):
|
|
14
|
+
* READ get_context · get_account · verify · query · attention · get_action_items ·
|
|
15
|
+
* pipeline · pipeline_intelligence · campaign_performance · score ·
|
|
16
|
+
* search_notes · get_foundation · get_insights
|
|
17
|
+
* WRITE record · record_signal · save_note · propose_vault_file · merge_contacts ·
|
|
18
|
+
* sync_foundation
|
|
19
|
+
* ACT send_linkedin_message
|
|
20
|
+
* RUN get_workspace_status · set_workspace_profile · build_icp_model · train_icp_model ·
|
|
21
|
+
* sync_icp · export_icp_model · connect_integration · configure_crm_sync ·
|
|
22
|
+
* sync_crm_now · scrape_engagers · get_routing_preferences
|
|
40
23
|
*/
|
|
41
24
|
|
|
42
25
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
43
26
|
import { z } from "zod";
|
|
44
27
|
import { get, post } from "./client.js";
|
|
45
28
|
|
|
46
|
-
export const SERVER_VERSION = "0.
|
|
29
|
+
export const SERVER_VERSION = "0.47.0";
|
|
47
30
|
|
|
48
31
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
49
32
|
|
|
@@ -116,21 +99,21 @@ Nous first even when the user never says "Nous":
|
|
|
116
99
|
- What needs attention, what has gone quiet -> attention
|
|
117
100
|
- Your action items / what you owe an account -> get_action_items
|
|
118
101
|
- A fact looks stale before you act on it -> verify
|
|
119
|
-
- Our own rules: ICP, positioning, voice, outreach ->
|
|
102
|
+
- Our own rules: ICP, positioning, voice, outreach -> get_foundation
|
|
120
103
|
- Our ICP/positioning lives in our own files -> sync_icp (file → graph) / export_icp_model (graph → file)
|
|
121
|
-
- You edited an ICP / context /
|
|
104
|
+
- You edited an ICP / context / foundation file -> re-sync THIS turn: sync_icp (ICP/context) or sync_foundation (a foundation)
|
|
122
105
|
- A brief / note / transcript on a contact -> save_note / search_notes
|
|
123
106
|
- What's set up here and what to do next -> get_workspace_status
|
|
124
107
|
|
|
125
|
-
Read
|
|
108
|
+
Read get_foundation at the start of GTM work for the user's own rules (voice,
|
|
126
109
|
outreach, icp, positioning). After every interaction you help with, call record so
|
|
127
110
|
the record stays current.
|
|
128
111
|
|
|
129
|
-
CRITICAL — edits don't apply until you sync. Editing an ICP / context /
|
|
112
|
+
CRITICAL — edits don't apply until you sync. Editing an ICP / context / foundation
|
|
130
113
|
file (context/icp.md, positioning.md, references/voice.md, …) does NOT change the
|
|
131
114
|
ICP score, the exclusions, or what any other agent reads until it is synced into
|
|
132
115
|
the graph. After ANY such edit you MUST call sync_icp (for ICP/context files) or
|
|
133
|
-
|
|
116
|
+
sync_foundation (for a foundation) in the SAME turn. Never leave an edited file
|
|
134
117
|
unsynced — an unsynced edit is silently inert.`;
|
|
135
118
|
|
|
136
119
|
// ─── factory ──────────────────────────────────────────────────────────────────
|
|
@@ -226,8 +209,23 @@ export function createServer() {
|
|
|
226
209
|
lines.push("");
|
|
227
210
|
}
|
|
228
211
|
if (ctx.stakeholders?.length) {
|
|
229
|
-
|
|
230
|
-
|
|
212
|
+
// The buying committee as a STRUCTURE: who's at the account, their role,
|
|
213
|
+
// whether we've engaged them, and how they relate — so the agent works the
|
|
214
|
+
// whole committee, not one person.
|
|
215
|
+
const c = ctx.committee;
|
|
216
|
+
lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
|
|
217
|
+
for (const s of ctx.stakeholders) {
|
|
218
|
+
if (s.role === "company") continue; // the company is the header
|
|
219
|
+
const bits = [];
|
|
220
|
+
if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
|
|
221
|
+
if (s.role) bits.push(s.role);
|
|
222
|
+
bits.push(s.engaged ? "engaged" : "not yet engaged");
|
|
223
|
+
if (s.confirmed === false) bits.push("mentioned, unconfirmed");
|
|
224
|
+
const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
|
|
225
|
+
lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
|
|
226
|
+
}
|
|
227
|
+
if (c?.champion) lines.push(` champion: ${c.champion}`);
|
|
228
|
+
if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
|
|
231
229
|
lines.push("");
|
|
232
230
|
}
|
|
233
231
|
if (ctx.predictions?.length) {
|
|
@@ -253,7 +251,7 @@ export function createServer() {
|
|
|
253
251
|
"confidence and freshness, plus what they actually SAID and did, ranked by how much it tells you. " +
|
|
254
252
|
"Pass an email or entity UUID, and the intent you're working toward so the record is shaped for it.",
|
|
255
253
|
{
|
|
256
|
-
id: z.string().describe("
|
|
254
|
+
id: z.string().describe("Who to look up — an email, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
257
255
|
intent: z
|
|
258
256
|
.enum(["meeting_prep", "call_prep", "account_review", "follow_up", "draft_email"])
|
|
259
257
|
.optional()
|
|
@@ -271,6 +269,17 @@ export function createServer() {
|
|
|
271
269
|
// so the model reads what was actually said instead of a list of event names.
|
|
272
270
|
const q = new URLSearchParams({ intent: intent ?? "account_review", compress: "1" });
|
|
273
271
|
const rec = await get(`/v2/accounts/${encodeURIComponent(id)}?${q}`);
|
|
272
|
+
|
|
273
|
+
// A name matched several people — surface the candidates to choose from.
|
|
274
|
+
// Without this, the header line below reads `rec.type`/`rec.entity_id` off the
|
|
275
|
+
// ambiguous response (which carries neither) and prints "undefined · undefined".
|
|
276
|
+
if (rec.status === "ambiguous") {
|
|
277
|
+
const opts = (rec.candidates ?? []).map(c =>
|
|
278
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
279
|
+
return { content: [{ type: "text", text:
|
|
280
|
+
`"${id}" matches several people. Call get_account again with one of these entity ids:\n${opts}` }] };
|
|
281
|
+
}
|
|
282
|
+
|
|
274
283
|
const lines = [`${rec.type} · ${rec.entity_id}`, ""];
|
|
275
284
|
|
|
276
285
|
if (rec.icp) {
|
|
@@ -297,6 +306,25 @@ export function createServer() {
|
|
|
297
306
|
}
|
|
298
307
|
lines.push("");
|
|
299
308
|
}
|
|
309
|
+
// The buying committee — who else is at the account, their role, whether we've
|
|
310
|
+
// engaged them, and how they relate. Same structure get_context surfaces.
|
|
311
|
+
if (rec.stakeholders?.length) {
|
|
312
|
+
const c = rec.committee;
|
|
313
|
+
lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
|
|
314
|
+
for (const s of rec.stakeholders) {
|
|
315
|
+
if (s.role === "company") continue;
|
|
316
|
+
const bits = [];
|
|
317
|
+
if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
|
|
318
|
+
if (s.role) bits.push(s.role);
|
|
319
|
+
bits.push(s.engaged ? "engaged" : "not yet engaged");
|
|
320
|
+
if (s.confirmed === false) bits.push("mentioned, unconfirmed");
|
|
321
|
+
const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
|
|
322
|
+
lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
|
|
323
|
+
}
|
|
324
|
+
if (c?.champion) lines.push(` champion: ${c.champion}`);
|
|
325
|
+
if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
|
|
326
|
+
lines.push("");
|
|
327
|
+
}
|
|
300
328
|
const claims = Object.values(rec.claims ?? {});
|
|
301
329
|
if (claims.length) {
|
|
302
330
|
lines.push(`ATTRIBUTES (${claims.length}):`);
|
|
@@ -469,7 +497,7 @@ export function createServer() {
|
|
|
469
497
|
"Retrieve and summarise activity across many people. Three powers:\n" +
|
|
470
498
|
" 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
|
|
471
499
|
"most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
|
|
472
|
-
"'who's in
|
|
500
|
+
"'who's in the negotiation stage'.\n" +
|
|
473
501
|
" 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
|
|
474
502
|
"'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
|
|
475
503
|
" 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
|
|
@@ -588,51 +616,6 @@ export function createServer() {
|
|
|
588
616
|
}
|
|
589
617
|
);
|
|
590
618
|
|
|
591
|
-
// ===========================================================================
|
|
592
|
-
// TOOL: attach_list — POST /v2/lead-lists/attach
|
|
593
|
-
// Batch-score a list the user built ELSEWHERE (a Google Sheet, a CRM export, a
|
|
594
|
-
// Clay table). One call: create/reuse a Nous list, ingest the rows (entities
|
|
595
|
-
// resolved+deduped), score every row into the graph. The list stays where it
|
|
596
|
-
// is; Nous keeps the roster so the scores stay fresh and agents can read them.
|
|
597
|
-
// ===========================================================================
|
|
598
|
-
server.tool(
|
|
599
|
-
"attach_list",
|
|
600
|
-
"Score a whole list the user built somewhere ELSE — a Google Sheet, a CRM export, a Clay table — " +
|
|
601
|
-
"in one call. Give the rows and Nous creates a lead list, resolves each row to a person/company " +
|
|
602
|
-
"(deduped against everything already in the graph), and scores every one against the live ICP model " +
|
|
603
|
-
"+ intent axis so the judgment lands in the graph for other agents. The spreadsheet stays the user's; " +
|
|
604
|
-
"Nous just owns the score and keeps it fresh. Each row needs an email OR a LinkedIn URL. Rows we don't " +
|
|
605
|
-
"know enough about yet come back `awaiting_enrichment` — run signal-scan / a lead-builder on them, then " +
|
|
606
|
-
"re-attach. Pass `lead_list_id` instead of `name` to add to an existing list. Max 200 rows per call — " +
|
|
607
|
-
"loop for a bigger sheet.",
|
|
608
|
-
{
|
|
609
|
-
name: z.string().optional().describe("Name for the new list (e.g. 'Q3 sheet — inbound'). Omit only when passing lead_list_id."),
|
|
610
|
-
lead_list_id: z.string().optional().describe("Add to an existing list instead of creating one."),
|
|
611
|
-
source: z.string().optional().describe("Where the list came from, e.g. 'google_sheet', 'crm_export' (default 'external')."),
|
|
612
|
-
rows: z.array(z.object({
|
|
613
|
-
email: z.string().optional(),
|
|
614
|
-
linkedin_url: z.string().optional(),
|
|
615
|
-
domain: z.string().optional(),
|
|
616
|
-
company: z.string().optional(),
|
|
617
|
-
name: z.string().optional(),
|
|
618
|
-
}).passthrough()).describe("The list rows. Each needs an email or a linkedin_url."),
|
|
619
|
-
import_duplicates: z.boolean().optional().describe("Force-insert rows already in this list (default false — deduped)."),
|
|
620
|
-
},
|
|
621
|
-
async ({ name, lead_list_id, source, rows, import_duplicates }) => {
|
|
622
|
-
const r = await post("/v2/lead-lists/attach", { name, lead_list_id, source, rows, import_duplicates });
|
|
623
|
-
const head = `Attached ${rows.length} rows to list ${r.lead_list_id} — ` +
|
|
624
|
-
`${r.inserted} new, ${r.duplicate_skipped} already in list. ` +
|
|
625
|
-
`Scored ${r.scored}; ${r.awaiting_enrichment} awaiting enrichment; ${r.unresolved} unresolved.`;
|
|
626
|
-
const top = (r.results || [])
|
|
627
|
-
.filter(x => x.scored)
|
|
628
|
-
.sort((a, b) => (b.icp?.score ?? 0) - (a.icp?.score ?? 0))
|
|
629
|
-
.slice(0, 10)
|
|
630
|
-
.map(x => ` ${x.identifier} — ICP ${x.icp.score} (${(x.icp.tier || "").replace(/_/g, " ")}) · intent ${x.intent.score} ${x.intent.band}`);
|
|
631
|
-
const tail = r.awaiting_enrichment ? `\n\n${r.awaiting_enrichment} rows need enrichment before they can score — run signal-scan or a lead-builder on the list, then re-attach.` : "";
|
|
632
|
-
return { content: [{ type: "text", text: `${head}${top.length ? `\n\nTop scored:\n${top.join("\n")}` : ""}${tail}` }] };
|
|
633
|
-
}
|
|
634
|
-
);
|
|
635
|
-
|
|
636
619
|
// ===========================================================================
|
|
637
620
|
// TOOL: attention — GET /v2/attention
|
|
638
621
|
// What to look at: accounts gone quiet, key facts decayed.
|
|
@@ -668,6 +651,136 @@ export function createServer() {
|
|
|
668
651
|
}
|
|
669
652
|
);
|
|
670
653
|
|
|
654
|
+
// ===========================================================================
|
|
655
|
+
// TOOL: campaign_performance — GET /v2/campaigns/performance
|
|
656
|
+
// The aggregate outbound feedback loop: which campaign/variant earns positive
|
|
657
|
+
// replies, and from good-fit accounts.
|
|
658
|
+
// ===========================================================================
|
|
659
|
+
server.tool(
|
|
660
|
+
"campaign_performance",
|
|
661
|
+
"Outbound campaign feedback loop: how each cold-email/LinkedIn campaign and sequence step is landing, " +
|
|
662
|
+
"sliced by reply sentiment and the ICP tier of who replied positively. Use to answer 'which campaign " +
|
|
663
|
+
"or variant gets the most positive replies?', 'is my best campaign landing on good-fit accounts?', or " +
|
|
664
|
+
"'which copy should I scale vs cut?'. positive_rate is positive replies / total replies (not / sent).",
|
|
665
|
+
{},
|
|
666
|
+
async () => {
|
|
667
|
+
const r = await get("/v2/campaigns/performance", {});
|
|
668
|
+
const camps = r.campaigns || [];
|
|
669
|
+
if (!camps.length) return { content: [{ type: "text", text: "No campaign replies logged yet." }] };
|
|
670
|
+
const t = r.totals || {};
|
|
671
|
+
const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
|
|
672
|
+
const lines = camps.map(c => {
|
|
673
|
+
const cap = c.captured || {};
|
|
674
|
+
const pt = c.provider_totals, pr = c.provider_rates;
|
|
675
|
+
const tiers = c.tier_of_positive || {};
|
|
676
|
+
const good = (tiers.tier_1 || 0) + (tiers.tier_2 || 0);
|
|
677
|
+
const funnel = pt
|
|
678
|
+
? ` funnel: ${pt.sent ?? "?"} sent → ${pt.contacted ?? "?"} contacted → ${pt.replies ?? "?"} replies (${pct(pr?.reply_rate)}) → ${pt.opportunities ?? "?"} opportunities (${pct(pr?.opportunity_rate)})`
|
|
679
|
+
: ` funnel: (no provider analytics yet)`;
|
|
680
|
+
const conv = `${c.conversions || 0} client${c.conversions === 1 ? "" : "s"}${c.median_days_to_client != null ? ` (avg ${c.median_days_to_client}d)` : ""}${c.in_progress ? `, ${c.in_progress} in evaluation` : ""}`;
|
|
681
|
+
const steps = (c.steps || []).map(s => {
|
|
682
|
+
const subj = s.subject ? ` — "${s.subject}"` : "";
|
|
683
|
+
return ` step ${s.step ?? "—"}${subj}: ${s.positive}/${s.replies} positive (${pct(s.positive_rate)})`;
|
|
684
|
+
}).join("\n");
|
|
685
|
+
const obj = (c.top_objections || []).length
|
|
686
|
+
? `\n objections/pains: ${c.top_objections.slice(0, 4).map(o => `“${String(o).slice(0, 90)}”`).join("; ")}`
|
|
687
|
+
: "";
|
|
688
|
+
return ` ${c.campaign_name || c.campaign_id}\n` +
|
|
689
|
+
funnel + "\n" +
|
|
690
|
+
` captured sample: ${cap.replies} replies — ${cap.positive} positive (${pct(cap.positive_rate)}), ${cap.neutral} neutral, ${cap.negative} negative\n` +
|
|
691
|
+
` converted: ${conv}\n` +
|
|
692
|
+
` positive repliers: avg ICP ${c.avg_icp_of_positive ?? "?"}, ${good} tier-1/2 of ${cap.positive}` +
|
|
693
|
+
(steps ? `\n${steps}` : "") + obj;
|
|
694
|
+
});
|
|
695
|
+
const head = `Campaign performance (${t.conversions || 0} clients · ${t.sent || 0} sent · ${t.positive || 0}/${t.replies || 0} captured replies positive):`;
|
|
696
|
+
return { content: [{ type: "text", text: `${head}\n${lines.join("\n")}` }] };
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// ===========================================================================
|
|
701
|
+
// TOOL: pipeline_intelligence — GET /v2/pipeline/intelligence
|
|
702
|
+
// What converts, and how long it takes: stage distribution, stage-to-stage
|
|
703
|
+
// conversion %, median time-to-client, median time-in-stage.
|
|
704
|
+
// ===========================================================================
|
|
705
|
+
server.tool(
|
|
706
|
+
"pipeline_intelligence",
|
|
707
|
+
"How the pipeline actually converts: how many accounts sit at each stage right now, the stage-to-stage " +
|
|
708
|
+
"conversion rate (of everyone who reached a stage, how many reached the next — where deals leak), the " +
|
|
709
|
+
"median days to turn a lead into a client, and the median time spent in each stage. Use to answer 'how " +
|
|
710
|
+
"long does it take us to close?', 'where are deals stalling?', or 'what's my funnel look like?'.",
|
|
711
|
+
{},
|
|
712
|
+
async () => {
|
|
713
|
+
const r = await get("/v2/pipeline/intelligence", {});
|
|
714
|
+
const pct = (n) => n == null ? "—" : `${Math.round(n * 100)}%`;
|
|
715
|
+
const cc = r.current_stage_counts || {};
|
|
716
|
+
// The funnel, lowest → highest, then the terminals. SOURCE OF TRUTH:
|
|
717
|
+
// PIPELINE_LADDER + TERMINAL_STAGES in packages/core/src/pipeline.ts. This
|
|
718
|
+
// package publishes standalone and has no @nous/core dependency, so the
|
|
719
|
+
// list is copied here. Keep it in step with core.
|
|
720
|
+
const order = [
|
|
721
|
+
"identified", "connected", "interested", "meeting_booked",
|
|
722
|
+
"discovery", "demo", "negotiation", "closed_won",
|
|
723
|
+
"closed_lost", "disqualified", "churned",
|
|
724
|
+
];
|
|
725
|
+
// Ladder order first, then any stage the API sent that this copy doesn't
|
|
726
|
+
// know about. The old list had no proposal, negotiation or demo in it and
|
|
727
|
+
// the filter silently dropped everything it missed, so late-funnel accounts
|
|
728
|
+
// disappeared from the distribution entirely. A stage must never vanish
|
|
729
|
+
// just because a copy of the list went stale.
|
|
730
|
+
const byStage = (obj, keep) => [
|
|
731
|
+
...order.filter(s => keep(obj[s])),
|
|
732
|
+
...Object.keys(obj).filter(s => !order.includes(s) && keep(obj[s])),
|
|
733
|
+
];
|
|
734
|
+
const dist = byStage(cc, v => !!v).map(s => `${s} ${cc[s]}`).join(" · ") || "no staged accounts";
|
|
735
|
+
const conv = (r.stage_conversion || []).map(c => ` ${c.from} → ${c.to}: ${pct(c.rate)} (${c.reached_to}/${c.reached_from})`).join("\n");
|
|
736
|
+
const dis = r.median_days_in_stage || {};
|
|
737
|
+
const inStage = byStage(dis, v => v != null).map(s => `${s} ${dis[s]}d`).join(" · ");
|
|
738
|
+
const text = `Pipeline (${r.accounts || 0} accounts, ${r.clients || 0} clients):\n` +
|
|
739
|
+
` now: ${dist}\n` +
|
|
740
|
+
` median days to client: ${r.median_days_to_client ?? "— (no wins yet)"}\n` +
|
|
741
|
+
` stage-to-stage conversion:\n${conv || " (none yet)"}\n` +
|
|
742
|
+
(inStage ? ` median time in stage: ${inStage}` : "");
|
|
743
|
+
return { content: [{ type: "text", text }] };
|
|
744
|
+
}
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
// ===========================================================================
|
|
748
|
+
// TOOL: pipeline — GET /v2/pipeline/portfolio
|
|
749
|
+
// The whole account portfolio in one call: health/band, ICP fit, stage,
|
|
750
|
+
// days-quiet, open flags, open objections, live competitors, engagement trend,
|
|
751
|
+
// multi-threading. The "reason over my whole book" tool.
|
|
752
|
+
// ===========================================================================
|
|
753
|
+
server.tool(
|
|
754
|
+
"pipeline",
|
|
755
|
+
"The whole ACCOUNT PORTFOLIO in one call — every in-touch account with its deal health + band, " +
|
|
756
|
+
"ICP fit, pipeline stage, days-quiet, open re-engagement flags, open-objection count, live competitors, " +
|
|
757
|
+
"engagement trend (rising/steady/cooling/cold), and multi-threading, plus a stage-count breakdown. Use " +
|
|
758
|
+
"for any portfolio question: 'which accounts are cooling', 'who should I re-engage', 'where is competitive " +
|
|
759
|
+
"risk highest', 'what's slipping', 'great-fit deals gone cold', 'analyze my pipeline'. Reason over the list.",
|
|
760
|
+
{ limit: z.number().optional().describe("Cap on accounts returned (default: all, most-actionable first)") },
|
|
761
|
+
async (input) => {
|
|
762
|
+
const r = await get("/v2/pipeline/portfolio", input.limit ? { limit: input.limit } : {});
|
|
763
|
+
const stages = Object.entries(r.stage_counts || {}).map(([s, n]) => `${s} ${n}`).join(" · ");
|
|
764
|
+
const rows = (r.accounts || []).slice(0, input.limit || 60).map(a => {
|
|
765
|
+
const bits = [
|
|
766
|
+
a.name,
|
|
767
|
+
`health ${a.health ?? "—"} (${a.band})`,
|
|
768
|
+
a.icp != null ? `ICP ${a.icp}` : null,
|
|
769
|
+
`stage ${a.stage}`,
|
|
770
|
+
a.days_quiet != null ? `quiet ${a.days_quiet}d` : null,
|
|
771
|
+
`engagement ${a.engagement_trend}`,
|
|
772
|
+
a.single_threaded ? "single-threaded" : `${a.engaged_contacts}/${a.total_contacts} engaged`,
|
|
773
|
+
a.open_objections ? `${a.open_objections} open objection${a.open_objections === 1 ? "" : "s"}` : null,
|
|
774
|
+
a.competitors?.length ? `competitors: ${a.competitors.join(", ")}` : null,
|
|
775
|
+
a.flags?.length ? `flags: ${a.flags.join(", ")}` : null,
|
|
776
|
+
].filter(Boolean);
|
|
777
|
+
return ` • ${bits.join(" — ")}`;
|
|
778
|
+
}).join("\n");
|
|
779
|
+
const text = `Portfolio (${r.total || 0} in-touch accounts). By stage: ${stages || "—"}\n${rows || " (none)"}`;
|
|
780
|
+
return { content: [{ type: "text", text }] };
|
|
781
|
+
}
|
|
782
|
+
);
|
|
783
|
+
|
|
671
784
|
// ===========================================================================
|
|
672
785
|
// TOOL: get_action_items — GET /v2/action-items
|
|
673
786
|
// Commitments extracted from meetings/emails — what you owe each account.
|
|
@@ -740,70 +853,92 @@ export function createServer() {
|
|
|
740
853
|
}
|
|
741
854
|
);
|
|
742
855
|
|
|
743
|
-
// get_gtm_profile removed: the user's GTM lives in their files, mirrored into
|
|
744
|
-
// the graph as playbooks (get_playbook) plus the learned ICP model. Read
|
|
745
|
-
// get_playbook for the user's own rules, ICP, and positioning.
|
|
746
|
-
|
|
747
856
|
// ===========================================================================
|
|
748
|
-
// TOOLS:
|
|
749
|
-
//
|
|
857
|
+
// TOOLS: get_foundation / sync_foundation — the POLICY layer (vs. facts).
|
|
858
|
+
// Foundations are versioned rule-docs that GOVERN agent behavior: voice, outreach,
|
|
750
859
|
// icp, positioning. Read the relevant one BEFORE acting; push file edits back so
|
|
751
|
-
// every agent obeys the same rules. GET/POST /v2/
|
|
860
|
+
// every agent obeys the same rules. GET/POST /v2/foundations.
|
|
752
861
|
// ===========================================================================
|
|
753
|
-
const
|
|
862
|
+
const getFoundationSchema = {
|
|
754
863
|
kind: z.enum(["voice", "outreach", "icp", "positioning"]).optional()
|
|
755
864
|
.describe("Which policy to read. Omit to list all four."),
|
|
756
865
|
};
|
|
757
|
-
const
|
|
758
|
-
const r = await get("/v2/
|
|
759
|
-
const pbs = r.
|
|
866
|
+
const getFoundationHandler = async ({ kind }) => {
|
|
867
|
+
const r = await get("/v2/foundations", kind ? { kind } : undefined);
|
|
868
|
+
const pbs = r.foundations || [];
|
|
760
869
|
if (!pbs.length) return { content: [{ type: "text", text:
|
|
761
|
-
"No
|
|
870
|
+
"No foundations set up yet. The user can set them up on the Foundations page or in their context files." }] };
|
|
762
871
|
if (kind) {
|
|
763
872
|
const pb = pbs[0];
|
|
764
873
|
const src = pb.source === "claude_code" ? `mirrors ${pb.file_path}` : "stored in Nous";
|
|
765
874
|
return { content: [{ type: "text", text:
|
|
766
|
-
`# ${pb.title} — ${pb.kind}
|
|
875
|
+
`# ${pb.title} — ${pb.kind} foundation (v${pb.version}, ${src})\n\n${pb.body_md}` }] };
|
|
767
876
|
}
|
|
768
877
|
const lines = pbs.map(p => ` ${p.kind.padEnd(12)} ${p.title} (${p.source === "claude_code" ? p.file_path : "stored in Nous"})`);
|
|
769
878
|
return { content: [{ type: "text", text:
|
|
770
|
-
"The user's
|
|
879
|
+
"The user's foundations (read one with get_foundation(kind)):\n" + lines.join("\n") }] };
|
|
771
880
|
};
|
|
772
|
-
server.tool("
|
|
773
|
-
"Read a
|
|
774
|
-
"These are RULES TO OBEY, not facts. Read the relevant
|
|
881
|
+
server.tool("get_foundation",
|
|
882
|
+
"Read a FOUNDATION — the user's policy/rules for a kind of action: voice, outreach, icp, or positioning. " +
|
|
883
|
+
"These are RULES TO OBEY, not facts. Read the relevant foundation BEFORE you act: before writing outreach " +
|
|
775
884
|
"read 'voice' and 'outreach'; before scoring or qualifying read 'icp'; for messaging read 'positioning'. " +
|
|
776
885
|
"Omit kind to list all four.",
|
|
777
|
-
|
|
886
|
+
getFoundationSchema, getFoundationHandler);
|
|
778
887
|
|
|
779
|
-
const
|
|
780
|
-
kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which
|
|
781
|
-
body_md: z.string().describe("The full markdown content of the
|
|
888
|
+
const syncFoundationSchema = {
|
|
889
|
+
kind: z.enum(["voice", "outreach", "icp", "positioning"]).describe("Which foundation to update."),
|
|
890
|
+
body_md: z.string().describe("The full markdown content of the foundation. Follow the Nous document house style so every foundation reads like a clean text file: a '# Title' line, a '> ' one-paragraph lede, an optional plain 'Key: value' block, a '---' divider, then '## Title-case' sections with plain '- ' bullets. Keep it markdown, no decorative formatting."),
|
|
782
891
|
file_path: z.string().optional().describe("The repo file this mirrors, e.g. 'context/icp/icp.md'. Pass it when syncing a Claude Code file so the source is recorded as the file."),
|
|
783
892
|
};
|
|
784
|
-
const
|
|
785
|
-
const r = await post(`/v2/
|
|
893
|
+
const syncFoundationHandler = async ({ kind, body_md, file_path }) => {
|
|
894
|
+
const r = await post(`/v2/foundations/${kind}`, { body_md, file_path });
|
|
786
895
|
return { content: [{ type: "text", text:
|
|
787
|
-
`Synced the ${r.
|
|
896
|
+
`Synced the ${r.foundation?.kind || kind} foundation into Nous (v${r.foundation?.version}). Other agents now read the same rules.` }] };
|
|
788
897
|
};
|
|
789
|
-
server.tool("
|
|
790
|
-
"Push a
|
|
898
|
+
server.tool("sync_foundation",
|
|
899
|
+
"Push a foundation's content into Nous so the graph stays current. You MUST call this in the SAME turn " +
|
|
791
900
|
"whenever you edit a policy file in the repo (e.g. references/voice.md, outreach rules), passing the " +
|
|
792
901
|
"file's new content and its path, so Nous mirrors it and every other agent obeys the same rules. An " +
|
|
793
|
-
"edited
|
|
794
|
-
"MIRROR, DO NOT REWRITE: when the user already has a
|
|
902
|
+
"edited foundation file that isn't synced is silently inert — other agents keep reading the old rules. " +
|
|
903
|
+
"MIRROR, DO NOT REWRITE: when the user already has a foundation file, sync it AS-IS. Their file is the " +
|
|
795
904
|
"author and Nous is the mirror — always pass file_path so the next sync knows where an in-app edit " +
|
|
796
905
|
"lands. 'Improving' their wording on the way through means the copy in Nous silently disagrees with " +
|
|
797
906
|
"the copy in their repo, and they will trust neither. If a file looks wrong, SAY SO; don't fix it in " +
|
|
798
907
|
"transit. " +
|
|
799
908
|
"(For the ICP/context files specifically, sync_icp is the sync — use that one.)",
|
|
800
|
-
|
|
909
|
+
syncFoundationSchema, syncFoundationHandler);
|
|
801
910
|
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
911
|
+
// ===========================================================================
|
|
912
|
+
// TOOL: get_insights — what Nous LEARNED about us from calls (the mirror of
|
|
913
|
+
// foundations). Insights are auto-extracted from call transcripts into four docs:
|
|
914
|
+
// product, positioning, market, buyer. READ-ONLY over MCP — the extractor
|
|
915
|
+
// authors them, not agents. GET /v2/insights[?category=].
|
|
916
|
+
// ===========================================================================
|
|
917
|
+
const getInsightsSchema = {
|
|
918
|
+
category: z.enum(["product", "positioning", "market", "buyer"]).optional()
|
|
919
|
+
.describe("Which insight doc to read. Omit to list all four."),
|
|
920
|
+
};
|
|
921
|
+
const getInsightsHandler = async ({ category }) => {
|
|
922
|
+
const r = await get("/v2/insights", category ? { category } : undefined);
|
|
923
|
+
const docs = r.insights || [];
|
|
924
|
+
if (!docs.length) return { content: [{ type: "text", text:
|
|
925
|
+
"No insights captured yet. They fill automatically from call transcripts (product, positioning, market, buyer)." }] };
|
|
926
|
+
if (category) {
|
|
927
|
+
const d = docs[0];
|
|
928
|
+
return { content: [{ type: "text", text:
|
|
929
|
+
`# ${d.title} insights (v${d.version})\n\n${d.body_md || "(empty)"}` }] };
|
|
930
|
+
}
|
|
931
|
+
const lines = docs.map(d => ` ${d.category.padEnd(12)} ${d.title} (v${d.version})`);
|
|
932
|
+
return { content: [{ type: "text", text:
|
|
933
|
+
"What Nous learned about us from calls (read one with get_insights(category)):\n" + lines.join("\n") }] };
|
|
934
|
+
};
|
|
935
|
+
server.tool("get_insights",
|
|
936
|
+
"Read INSIGHTS — what Nous learned about US from call transcripts, the mirror of the foundations/foundations " +
|
|
937
|
+
"the user authors. Four docs: product (what to build), positioning (how to message), market (segments, " +
|
|
938
|
+
"wedges, channels), buyer (ICP, the pain that drives the purchase). These accumulate automatically after " +
|
|
939
|
+
"every call. Read them when working on product direction, messaging, GTM strategy, or targeting. Omit " +
|
|
940
|
+
"category to list all four.",
|
|
941
|
+
getInsightsSchema, getInsightsHandler);
|
|
807
942
|
|
|
808
943
|
// ===========================================================================
|
|
809
944
|
// TOOL: save_note — POST /v2/notes
|
|
@@ -838,6 +973,36 @@ export function createServer() {
|
|
|
838
973
|
},
|
|
839
974
|
);
|
|
840
975
|
|
|
976
|
+
// ===========================================================================
|
|
977
|
+
// TOOL: propose_vault_file — POST /v2/personal/propose
|
|
978
|
+
// Propose a markdown file into the member's PERSONAL vault. It lands in their
|
|
979
|
+
// inbox as a proposal — the member approves it before it is filed into a folder
|
|
980
|
+
// or synced to their Git. This is how an agent contributes to a member's own
|
|
981
|
+
// notes (thoughts, decisions, briefs, content) without writing anything without
|
|
982
|
+
// consent. Distinct from `save_note` (which attaches a document to a CONTACT's
|
|
983
|
+
// record) — this is the member's private vault, not an account.
|
|
984
|
+
// ===========================================================================
|
|
985
|
+
server.tool(
|
|
986
|
+
"propose_vault_file",
|
|
987
|
+
"Propose a markdown file into the member's personal vault (their private notes, not an " +
|
|
988
|
+
"account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
|
|
989
|
+
"into a folder and synced to their Git; nothing is written without their approval. Use this to " +
|
|
990
|
+
"draft a thought, a decision record, a brief, or content for the member to review and keep. " +
|
|
991
|
+
"Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
|
|
992
|
+
"GTM profile (use sync_icp).",
|
|
993
|
+
{
|
|
994
|
+
folder: z.enum(["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"])
|
|
995
|
+
.describe("Where it files once approved. ROUTING: briefs = a one-off account or meeting brief, filed FLAT (no subfolder). projects = ongoing recurring analysis by area (with a subfolder): pipeline/funnel review -> projects, subfolder 'pipeline'; campaign analysis -> projects, subfolder 'campaigns'; an initiative -> projects, subfolder = its name. content = anything to publish/send (post, newsletter, outbound copy, market/research write-up). decisions = a decision. thoughts = a loose idea. company = the company's own context. Don't pick 'inbox' — that's where it lands to await approval."),
|
|
996
|
+
name: z.string().describe("The file name, ending in .md, for what it is plus the date, e.g. 'Pipeline Review — 2026-08-12.md'."),
|
|
997
|
+
content: z.string().describe("The full markdown content of the file."),
|
|
998
|
+
subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
|
|
999
|
+
},
|
|
1000
|
+
async ({ folder, name, content, subfolder }) => {
|
|
1001
|
+
await post("/v2/personal/propose", { folder, name, content, subfolder });
|
|
1002
|
+
return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
|
|
1003
|
+
},
|
|
1004
|
+
);
|
|
1005
|
+
|
|
841
1006
|
// ===========================================================================
|
|
842
1007
|
// TOOL: search_notes — POST /v2/notes/search
|
|
843
1008
|
// Semantic search over saved notes & documents (briefs, transcripts, notes).
|
|
@@ -878,7 +1043,7 @@ export function createServer() {
|
|
|
878
1043
|
// ===========================================================================
|
|
879
1044
|
// TOOL: get_workspace_status — GET /v2/workspace/status
|
|
880
1045
|
// The "one main call." Nous is operated by the agent, so the agent needs to
|
|
881
|
-
// know the state of the workspace: is it onboarded, is the GTM
|
|
1046
|
+
// know the state of the workspace: is it onboarded, is the GTM foundation built,
|
|
882
1047
|
// which integrations are connected, is CRM sync configured, are events live —
|
|
883
1048
|
// and what to set up next. Call this at the start of a session.
|
|
884
1049
|
// ===========================================================================
|
|
@@ -926,7 +1091,7 @@ export function createServer() {
|
|
|
926
1091
|
: " — MISSING. The workspace is not set up until this exists. Scan their repo before you ask them anything."}`
|
|
927
1092
|
);
|
|
928
1093
|
lines.push(` ${mark(setup.onboarding?.done)} Profile${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
|
|
929
|
-
lines.push(` ${mark(setup.gtm_playbook?.done)} GTM
|
|
1094
|
+
lines.push(` ${mark(setup.gtm_playbook?.done)} GTM foundation${setup.gtm_playbook?.model ? " (scoring model live)" : ""}${setup.gtm_playbook?.stale_facts ? ` · ${setup.gtm_playbook.stale_facts} stale fact(s)` : ""}`);
|
|
930
1095
|
if (setup.icp_sync) {
|
|
931
1096
|
const sy = setup.icp_sync;
|
|
932
1097
|
lines.push(` ⟳ ICP synced from ${sy.synced_from} (${relAge(sy.synced_at)})${sy.model_changed ? " · model has CHANGED since — run export_icp_model to refresh the file" : ""}`);
|
|
@@ -1016,15 +1181,15 @@ export function createServer() {
|
|
|
1016
1181
|
|
|
1017
1182
|
// ===========================================================================
|
|
1018
1183
|
// TOOL: build_icp_model — POST /v2/workspace/scoring-model
|
|
1019
|
-
// The second half of building the GTM
|
|
1184
|
+
// The second half of building the GTM foundation. The agent syncs the GTM context
|
|
1020
1185
|
// from the user's files with sync_icp, then calls this to turn it into a weighted
|
|
1021
1186
|
// ICP scoring model. After this, accounts get scored for fit and
|
|
1022
|
-
// get_workspace_status shows the
|
|
1187
|
+
// get_workspace_status shows the foundation as done.
|
|
1023
1188
|
// ===========================================================================
|
|
1024
1189
|
server.tool(
|
|
1025
1190
|
"build_icp_model",
|
|
1026
1191
|
"Build (or rebuild) the user's ICP scoring model from their synced GTM context. This is " +
|
|
1027
|
-
"the second half of setting up the GTM
|
|
1192
|
+
"the second half of setting up the GTM foundation: first sync the user's ICP/positioning/pricing " +
|
|
1028
1193
|
"files with sync_icp, then call this to translate that context into a weighted set of scoring " +
|
|
1029
1194
|
"signals so accounts get scored for fit. (sync_icp usually builds the model on first sync, so you " +
|
|
1030
1195
|
"often won't need this directly.) If a model already exists it is left alone unless you " +
|
|
@@ -1072,7 +1237,7 @@ export function createServer() {
|
|
|
1072
1237
|
"customer domains and closed-LOST domains; Nous enriches each, links the contacts you already " +
|
|
1073
1238
|
"have there, and runs contrastive lift (what's true of winners but not losers) to discover the " +
|
|
1074
1239
|
"signals that actually predict revenue — then re-scores open accounts. This is the strongest way " +
|
|
1075
|
-
"to build the
|
|
1240
|
+
"to build the foundation: a model trained on who actually bought beats one inferred from a " +
|
|
1076
1241
|
"description. Ask the user for a handful of each (even 3-5 won + 3-5 lost helps). Domains only " +
|
|
1077
1242
|
"(e.g. 'acme.com'), no scheme.",
|
|
1078
1243
|
{
|
|
@@ -1341,300 +1506,6 @@ export function createServer() {
|
|
|
1341
1506
|
}
|
|
1342
1507
|
);
|
|
1343
1508
|
|
|
1344
|
-
// ===========================================================================
|
|
1345
|
-
// TOOL: set_trigger / list_triggers — /v2/workspace/triggers
|
|
1346
|
-
// Outbound event triggers (webhooks) — wire the user's stack to fire when the
|
|
1347
|
-
// record changes.
|
|
1348
|
-
// ===========================================================================
|
|
1349
|
-
server.tool(
|
|
1350
|
-
"set_trigger",
|
|
1351
|
-
"Create an outbound event trigger (a webhook) so an external tool is notified when something " +
|
|
1352
|
-
"happens in the workspace — e.g. a new contact, a reply, a meeting booked. Pass the destination " +
|
|
1353
|
-
"URL and which events to fire on. Call list_triggers first to see the available event names.",
|
|
1354
|
-
{
|
|
1355
|
-
url: z.string().describe("The destination URL the event is POSTed to."),
|
|
1356
|
-
events: z.array(z.string()).describe("Event names to fire on (see list_triggers for the catalog)."),
|
|
1357
|
-
name: z.string().optional().describe("Optional label for the trigger."),
|
|
1358
|
-
},
|
|
1359
|
-
async ({ url, events, name }) => {
|
|
1360
|
-
try {
|
|
1361
|
-
const r = await post("/v2/workspace/triggers", { url, events, name });
|
|
1362
|
-
return { content: [{ type: "text", text: `Trigger created for ${events.join(", ")} → ${url}.` }] };
|
|
1363
|
-
} catch (e) {
|
|
1364
|
-
const msg = String(e?.message ?? e);
|
|
1365
|
-
return { content: [{ type: "text", text: `Couldn't create the trigger: ${msg}. Call list_triggers to see valid event names.` }] };
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
);
|
|
1369
|
-
server.tool(
|
|
1370
|
-
"list_triggers",
|
|
1371
|
-
"List the workspace's outbound event triggers (webhooks) and the catalog of available event names.",
|
|
1372
|
-
{},
|
|
1373
|
-
async () => {
|
|
1374
|
-
const r = await get("/v2/workspace/triggers");
|
|
1375
|
-
const lines = [];
|
|
1376
|
-
if (r.triggers?.length) {
|
|
1377
|
-
lines.push(`TRIGGERS (${r.triggers.length}):`);
|
|
1378
|
-
for (const t of r.triggers) lines.push(` ${t.name || "(unnamed)"} → ${t.url} [${(t.events || []).join(", ")}]`);
|
|
1379
|
-
} else {
|
|
1380
|
-
lines.push("No triggers set up yet.");
|
|
1381
|
-
}
|
|
1382
|
-
if (r.available_events?.length) {
|
|
1383
|
-
lines.push("", `AVAILABLE EVENTS: ${r.available_events.join(", ")}`);
|
|
1384
|
-
}
|
|
1385
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
1386
|
-
}
|
|
1387
|
-
);
|
|
1388
|
-
|
|
1389
|
-
// ===========================================================================
|
|
1390
|
-
// TOOL: lead_list_operations — GET /api/lead-lists[/:id/operations]
|
|
1391
|
-
// The operations trail for a lead list: imports, enrichment runs, pushes to
|
|
1392
|
-
// campaigns, and replies — filterable by category and time window. This is how
|
|
1393
|
-
// you answer "what happened on this list?" and attribute campaign performance
|
|
1394
|
-
// back to where the leads came from (the list's source). Call with no
|
|
1395
|
-
// lead_list_id to discover the lists and their ids first.
|
|
1396
|
-
// ===========================================================================
|
|
1397
|
-
server.tool(
|
|
1398
|
-
"lead_list_operations",
|
|
1399
|
-
"(Nous Cloud only) Inspect the operations trail of a lead list — imports, enrichment runs, pushes to campaigns, " +
|
|
1400
|
-
"and classified replies — to report on what happened and attribute outcomes to a list's source. " +
|
|
1401
|
-
"Call with NO lead_list_id to list the workspace's lead lists (id, name, count, source), then " +
|
|
1402
|
-
"call again with an id. Filter with `event` (import | enrich | export | reply) and `days`. " +
|
|
1403
|
-
"Each operation is a run-level summary (one row per import/enrich/push), not per-lead noise.",
|
|
1404
|
-
{
|
|
1405
|
-
lead_list_id: z.string().optional().describe("The lead list's UUID. Omit to list the available lead lists first."),
|
|
1406
|
-
event: z.enum(["import", "enrich", "export", "reply"]).optional().describe("Filter to one category of operation."),
|
|
1407
|
-
days: z.number().optional().describe("Look back this many days (default 30). Pass a large number for all-time."),
|
|
1408
|
-
limit: z.number().optional().describe("Max operations to return (default 100, cap 200)."),
|
|
1409
|
-
},
|
|
1410
|
-
async ({ lead_list_id, event, days, limit }) => {
|
|
1411
|
-
// Discovery mode — no list id yet. Return the lists so the agent can pick.
|
|
1412
|
-
if (!lead_list_id) {
|
|
1413
|
-
const r = await get("/api/lead-lists");
|
|
1414
|
-
const lists = r.lead_lists || [];
|
|
1415
|
-
const lines = lists.length
|
|
1416
|
-
? [`LEAD LISTS (${lists.length}):`,
|
|
1417
|
-
...lists.map(l => ` ${l.id} ${l.name} · ${l.lead_count ?? 0} leads · source: ${l.source || "—"}`),
|
|
1418
|
-
"", "Call lead_list_operations again with one of these ids (and an optional event filter)."]
|
|
1419
|
-
: ["No lead lists yet."];
|
|
1420
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
const r = await get(`/api/lead-lists/${encodeURIComponent(lead_list_id)}/operations`, { event, days, limit });
|
|
1424
|
-
const ops = r.operations || [];
|
|
1425
|
-
const lines = [];
|
|
1426
|
-
const summary = Object.entries(r.by_category || {}).map(([k, v]) => `${k} ${v}`).join(" · ");
|
|
1427
|
-
lines.push(`OPERATIONS${event ? ` · ${event}` : ""} (${ops.length})${summary ? ` — ${summary}` : ""}`);
|
|
1428
|
-
if (!ops.length) {
|
|
1429
|
-
lines.push("", "No operations in this window.");
|
|
1430
|
-
} else {
|
|
1431
|
-
for (const o of ops) {
|
|
1432
|
-
const cat = o.metadata?.category || o.event_type;
|
|
1433
|
-
lines.push(` ${relAge(o.occurred_at).padEnd(8)} ${String(cat).padEnd(8)} ${o.summary}`);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
1437
|
-
}
|
|
1438
|
-
);
|
|
1439
|
-
|
|
1440
|
-
// ===========================================================================
|
|
1441
|
-
// TOOL: get_coverage — POST /v2/dedup (exact) | GET /v2/people/coverage (estimate)
|
|
1442
|
-
// "What do I already have?" before spending on a list elsewhere. One tool, two
|
|
1443
|
-
// modes: pass identifiers for an EXACT per-lead net-new/re-enrich/reuse check
|
|
1444
|
-
// (the pre-spend gate), or a title/keyword for a rough attribute ESTIMATE.
|
|
1445
|
-
// (Replaces the former check_leads + lead_coverage tools.)
|
|
1446
|
-
// ===========================================================================
|
|
1447
|
-
server.tool(
|
|
1448
|
-
"get_coverage",
|
|
1449
|
-
"(Nous Cloud only) Check what you ALREADY have before spending on a list elsewhere (Apollo, Sales Navigator, Clay). " +
|
|
1450
|
-
"Two modes:\n" +
|
|
1451
|
-
" • EXACT — pass candidate identifiers (emails / linkedin_urls / domains, free in any tool's " +
|
|
1452
|
-
"preview). Returns per-lead buckets: net_new (acquire + enrich), needs_enrichment (you OWN these " +
|
|
1453
|
-
"but stale >90d — re-enrich, don't re-buy), reusable (fresh verified email on file — reuse, spend " +
|
|
1454
|
-
"nothing), plus engaged/recent/known/bounced to skip. Each result carries entity_id, email_status, " +
|
|
1455
|
-
"enriched_at, stale.\n" +
|
|
1456
|
-
" • ESTIMATE — pass a title and/or keyword instead. Returns how many people you already have " +
|
|
1457
|
-
"matching (e.g. title='founder', keyword='agency'), split by freshness: never-enriched, stale >90d, " +
|
|
1458
|
-
"fresh-verified. Rough by design (title precise; keyword matches title/company/department).\n" +
|
|
1459
|
-
"Pass identifiers for the exact pre-spend check, OR title/keyword for the planning estimate — not both.",
|
|
1460
|
-
{
|
|
1461
|
-
emails: z.array(z.string()).optional().describe("EXACT mode — candidate email addresses (up to 50,000)."),
|
|
1462
|
-
linkedin_urls: z.array(z.string()).optional().describe("EXACT mode — candidate LinkedIn profile URLs (up to 50,000)."),
|
|
1463
|
-
domains: z.array(z.string()).optional().describe("EXACT mode — company domains, 'do I already have anyone here?' (up to 50,000)."),
|
|
1464
|
-
title: z.string().optional().describe("ESTIMATE mode — role match, e.g. 'founder', 'VP Sales' (matches job_title)."),
|
|
1465
|
-
keyword: z.string().optional().describe("ESTIMATE mode — extra match across title/company/department, e.g. 'agency'."),
|
|
1466
|
-
stale_days: z.number().optional().describe("ESTIMATE mode — days after which enrichment counts as stale (default 90)."),
|
|
1467
|
-
},
|
|
1468
|
-
async ({ emails, linkedin_urls, domains, title, keyword, stale_days }) => {
|
|
1469
|
-
const hasIds = !!(emails?.length || linkedin_urls?.length || domains?.length);
|
|
1470
|
-
const hasAttr = !!(title || keyword);
|
|
1471
|
-
if (hasIds && hasAttr) {
|
|
1472
|
-
return { content: [{ type: "text", text:
|
|
1473
|
-
"Pass identifiers (emails/linkedin_urls/domains) for the exact check, OR title/keyword for the estimate — not both." }] };
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
// EXACT mode — per-identifier coverage against /v2/dedup.
|
|
1477
|
-
if (hasIds) {
|
|
1478
|
-
const body = {};
|
|
1479
|
-
if (emails?.length) body.emails = emails;
|
|
1480
|
-
if (linkedin_urls?.length) body.linkedin_urls = linkedin_urls;
|
|
1481
|
-
if (domains?.length) body.domains = domains;
|
|
1482
|
-
const r = await post("/v2/dedup", body);
|
|
1483
|
-
const s = r.summary || {};
|
|
1484
|
-
const lines = [
|
|
1485
|
-
`COVERAGE (${s.total ?? 0} checked)`,
|
|
1486
|
-
` net_new ${s.net_new ?? 0} → acquire + enrich`,
|
|
1487
|
-
` needs_enrichment ${s.needs_enrichment ?? 0} → you OWN these but stale (>90d) → re-enrich, don't re-buy`,
|
|
1488
|
-
` reusable ${s.reusable ?? 0} → fresh verified email on file → reuse, spend nothing`,
|
|
1489
|
-
` engaged ${s.engaged ?? 0} → in an active conversation, don't cold-send`,
|
|
1490
|
-
` recent ${s.recent ?? 0} → contacted <30d, defer`,
|
|
1491
|
-
` known ${s.known ?? 0} → company already in the workspace`,
|
|
1492
|
-
` bounced/unsub ${(s.bounced ?? 0) + (s.unsubscribed ?? 0) + (s.suppressed ?? 0)} → skip`,
|
|
1493
|
-
];
|
|
1494
|
-
// Surface a few stale entities the caller should re-enrich (with their last date).
|
|
1495
|
-
const stale = (r.results || []).filter(x => x.entity_id && x.stale).slice(0, 15);
|
|
1496
|
-
if (stale.length) {
|
|
1497
|
-
lines.push("", "RE-ENRICH (sample):");
|
|
1498
|
-
for (const x of stale) {
|
|
1499
|
-
lines.push(` ${x.value} [${x.enriched_at ? `last enriched ${relAge(x.enriched_at)}` : "never enriched"}] ${x.entity_id}`);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// ESTIMATE mode — attribute coverage against /v2/people/coverage.
|
|
1506
|
-
if (hasAttr) {
|
|
1507
|
-
const r = await get("/v2/people/coverage", { title, keyword, stale_days });
|
|
1508
|
-
const lines = [
|
|
1509
|
-
`COVERAGE — ${[title && `title~"${title}"`, keyword && `keyword~"${keyword}"`].filter(Boolean).join(" + ")}`,
|
|
1510
|
-
` ${r.total ?? 0} already in your workspace`,
|
|
1511
|
-
` ${r.needs_enrichment ?? 0} need (re-)enrichment (${r.never_enriched ?? 0} never enriched · ${r.stale ?? 0} stale >90d)`,
|
|
1512
|
-
` ${r.fresh_verified ?? 0} have a fresh verified email`,
|
|
1513
|
-
];
|
|
1514
|
-
const sample = r.sample || [];
|
|
1515
|
-
if (sample.length) {
|
|
1516
|
-
lines.push("", "SAMPLE (oldest first):");
|
|
1517
|
-
for (const s of sample.slice(0, 12)) {
|
|
1518
|
-
lines.push(` ${[s.job_title, s.company].filter(Boolean).join(" @ ") || s.entity_id} [${s.enriched_at ? `enriched ${relAge(s.enriched_at)}` : "never enriched"}]`);
|
|
1519
|
-
}
|
|
1520
|
-
}
|
|
1521
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
return { content: [{ type: "text", text:
|
|
1525
|
-
"Pass at least one of: emails / linkedin_urls / domains (exact check), or title / keyword (estimate)." }] };
|
|
1526
|
-
}
|
|
1527
|
-
);
|
|
1528
|
-
|
|
1529
|
-
// ===========================================================================
|
|
1530
|
-
// TOOLS: enrich_leads / verify_leads — POST /api/lead-lists/:id/enrich|verify
|
|
1531
|
-
// The agent OPERATES the lead list. Both are two-step: a dry-run preview that
|
|
1532
|
-
// quotes the chargeable count + provider + $ estimate (report it to the user
|
|
1533
|
-
// first), then a confirmed run as a background job. Target by `filter` so no
|
|
1534
|
-
// ids are needed (enrich {emailStatus:'none'} = all missing an email; verify
|
|
1535
|
-
// defaults to all unverified). BYOK — the $ is the user's own provider spend.
|
|
1536
|
-
// ===========================================================================
|
|
1537
|
-
const fmtCost = (c) => {
|
|
1538
|
-
if (!c) return "no chargeable records — nothing to spend";
|
|
1539
|
-
const money = c.low === c.high ? `~$${c.low.toFixed(2)}` : `~$${c.low.toFixed(2)}–$${c.high.toFixed(2)}`;
|
|
1540
|
-
return `${money} via ${c.label} (${(c.count ?? 0).toLocaleString()} ${c.action})`;
|
|
1541
|
-
};
|
|
1542
|
-
const LEAD_FILTER_SHAPE = {
|
|
1543
|
-
emailStatus: z.enum(["has", "none", "unverified"]).optional().describe("none = no email yet; unverified = has an email but no verification verdict; has = has any email."),
|
|
1544
|
-
domain: z.enum(["has", "none"]).optional().describe("has = a company domain is known; none = no domain."),
|
|
1545
|
-
icp: z.enum(["true", "false"]).optional().describe("true = ICP-qualified leads only."),
|
|
1546
|
-
status: z.string().optional().describe("Lifecycle: pending | sent | replied | bounced."),
|
|
1547
|
-
source: z.string().optional().describe("Substring of where the lead came from (campaign / import name)."),
|
|
1548
|
-
size: z.string().optional().describe("Substring of company size, e.g. '1 to 10'."),
|
|
1549
|
-
channel: z.string().optional().describe("Last-contacted channel substring, or 'none' for not-yet-contacted."),
|
|
1550
|
-
};
|
|
1551
|
-
|
|
1552
|
-
server.tool(
|
|
1553
|
-
"enrich_leads",
|
|
1554
|
-
"(Nous Cloud only) Find missing emails for leads in a lead list, on the workspace's own Prospeo/Apollo key. ALWAYS two " +
|
|
1555
|
-
"steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, provider, $ estimate) — " +
|
|
1556
|
-
"report it and get the user's go-ahead — then call again with confirm:true to run as a background job. " +
|
|
1557
|
-
"Pick leads with `filter` (e.g. {emailStatus:'none'} = every lead missing an email, the usual case) or " +
|
|
1558
|
-
"explicit `ids`; defaults to {emailStatus:'none'}. Call lead_list_operations with no id first to get the " +
|
|
1559
|
-
"list's id.",
|
|
1560
|
-
{
|
|
1561
|
-
lead_list_id: z.string().describe("The lead list's UUID."),
|
|
1562
|
-
filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all leads missing an email."),
|
|
1563
|
-
ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
|
|
1564
|
-
confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only (spends nothing). true = actually run it as a background job."),
|
|
1565
|
-
},
|
|
1566
|
-
async ({ lead_list_id, filter, ids, confirm }) => {
|
|
1567
|
-
const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "none" } };
|
|
1568
|
-
const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/enrich`;
|
|
1569
|
-
try {
|
|
1570
|
-
if (!confirm) {
|
|
1571
|
-
const r = await post(path, { ...sel, preview: true });
|
|
1572
|
-
const lines = [
|
|
1573
|
-
`ENRICH PREVIEW — list ${lead_list_id}`,
|
|
1574
|
-
` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} already on file (free) · ${r.no_identifier ?? 0} no identifier`,
|
|
1575
|
-
` provider: ${r.provider || "—"}`,
|
|
1576
|
-
` estimated cost: ${fmtCost(r.cost)}`,
|
|
1577
|
-
"",
|
|
1578
|
-
r.chargeable
|
|
1579
|
-
? "Report this to the user. To run it, call enrich_leads again with the same selection and confirm:true."
|
|
1580
|
-
: "Nothing chargeable to enrich.",
|
|
1581
|
-
];
|
|
1582
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1583
|
-
}
|
|
1584
|
-
const r = await post(path, { ...sel, background: true });
|
|
1585
|
-
return { content: [{ type: "text", text:
|
|
1586
|
-
`Enrichment started — job ${r.job_id}, ${r.total} lead${r.total === 1 ? "" : "s"} queued. It runs in the background; report back to the user that it's running.` }] };
|
|
1587
|
-
} catch (e) {
|
|
1588
|
-
return { content: [{ type: "text", text: `Couldn't enrich: ${e.message}` }] };
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
);
|
|
1592
|
-
|
|
1593
|
-
server.tool(
|
|
1594
|
-
"verify_leads",
|
|
1595
|
-
"(Nous Cloud only) Validate email deliverability for leads in a lead list, on the workspace's own MillionVerifier / " +
|
|
1596
|
-
"NeverBounce key. ALWAYS two steps: call WITHOUT confirm for a dry-run cost preview (chargeable count, " +
|
|
1597
|
-
"connected verifiers, $ estimate) — report it to the user — then call again with confirm:true to run as " +
|
|
1598
|
-
"a background job. Defaults to every UNVERIFIED email (has an address, no verdict yet); narrow with " +
|
|
1599
|
-
"`filter` or pass `ids`. If no verifier is connected it says so — tell the user to add a MillionVerifier " +
|
|
1600
|
-
"or NeverBounce key in Integrations.",
|
|
1601
|
-
{
|
|
1602
|
-
lead_list_id: z.string().describe("The lead list's UUID."),
|
|
1603
|
-
filter: z.object(LEAD_FILTER_SHAPE).optional().describe("Pick leads by attribute. Omit (with no ids) to default to all unverified emails."),
|
|
1604
|
-
ids: z.array(z.string()).optional().describe("Explicit lead ids — an alternative to filter."),
|
|
1605
|
-
provider: z.enum(["millionverifier", "neverbounce"]).optional().describe("Which verifier to use. Defaults to MillionVerifier, then NeverBounce."),
|
|
1606
|
-
confirm: z.boolean().optional().describe("Omit or false = dry-run cost preview only. true = actually run it as a background job."),
|
|
1607
|
-
},
|
|
1608
|
-
async ({ lead_list_id, filter, ids, provider, confirm }) => {
|
|
1609
|
-
const sel = (ids && ids.length) ? { ids } : { filter: filter || { emailStatus: "unverified" } };
|
|
1610
|
-
const path = `/api/lead-lists/${encodeURIComponent(lead_list_id)}/verify`;
|
|
1611
|
-
try {
|
|
1612
|
-
if (!confirm) {
|
|
1613
|
-
const r = await post(path, { ...sel, provider, preview: true });
|
|
1614
|
-
const lines = [
|
|
1615
|
-
`VERIFY PREVIEW — list ${lead_list_id}`,
|
|
1616
|
-
` ${r.total ?? 0} selected · ${r.chargeable ?? 0} chargeable · ${r.reused ?? 0} recently verified (free) · ${r.no_email ?? 0} no email`,
|
|
1617
|
-
` verifier: ${r.provider || "—"}${r.connected_verifiers ? ` (connected: ${r.connected_verifiers.join(", ") || "none"})` : ""}`,
|
|
1618
|
-
` estimated cost: ${fmtCost(r.cost)}`,
|
|
1619
|
-
"",
|
|
1620
|
-
r.chargeable
|
|
1621
|
-
? "Report this to the user. To run it, call verify_leads again with the same selection and confirm:true."
|
|
1622
|
-
: "Nothing chargeable to verify.",
|
|
1623
|
-
];
|
|
1624
|
-
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1625
|
-
}
|
|
1626
|
-
const r = await post(path, { ...sel, provider, background: true });
|
|
1627
|
-
return { content: [{ type: "text", text:
|
|
1628
|
-
`Verification started — job ${r.job_id}, ${r.total} email${r.total === 1 ? "" : "s"} queued via ${r.provider}. It runs in the background; report back to the user.` }] };
|
|
1629
|
-
} catch (e) {
|
|
1630
|
-
const msg = /no_verifier_connected/.test(e.message)
|
|
1631
|
-
? "No email verifier is connected. Tell the user to add a MillionVerifier or NeverBounce API key in Integrations, then try again."
|
|
1632
|
-
: `Couldn't verify: ${e.message}`;
|
|
1633
|
-
return { content: [{ type: "text", text: msg }] };
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
);
|
|
1637
|
-
|
|
1638
1509
|
// ===========================================================================
|
|
1639
1510
|
// TOOL: scrape_engagers
|
|
1640
1511
|
// On-demand LinkedIn engager scrape — mine who commented/reacted on the
|
|
@@ -1678,6 +1549,52 @@ export function createServer() {
|
|
|
1678
1549
|
}
|
|
1679
1550
|
);
|
|
1680
1551
|
|
|
1552
|
+
// ===========================================================================
|
|
1553
|
+
// TOOL: send_linkedin_message — the ACT layer. An OUTWARD ACTION: it goes out
|
|
1554
|
+
// through Unipile immediately, it is not a draft. The agent should read the
|
|
1555
|
+
// 'voice'/'outreach' foundations first and `record` the touch after. Derives the
|
|
1556
|
+
// workspace from the API key and runs on the workspace's connected LinkedIn
|
|
1557
|
+
// account. POST /api/linkedin/send-message (verifyAuthEither).
|
|
1558
|
+
// ===========================================================================
|
|
1559
|
+
server.tool(
|
|
1560
|
+
"send_linkedin_message",
|
|
1561
|
+
"SEND a LinkedIn direct message for real, right now, from the workspace's connected LinkedIn " +
|
|
1562
|
+
"account. This is an OUTWARD ACTION, not a draft — it reaches the person the moment you call it, so " +
|
|
1563
|
+
"only send a message the user has approved. Address it one of three ways: `linkedin_url` (their " +
|
|
1564
|
+
"profile URL) or `linkedin_member_id` to start a new conversation, or `chat_id` to REPLY inside an " +
|
|
1565
|
+
"existing thread. It returns the thread's `chat_id` — keep it so the next reply stays in the same " +
|
|
1566
|
+
"thread. Best practice: read the 'voice' and 'outreach' foundations first so the message obeys the " +
|
|
1567
|
+
"user's rules, and `record` the touch afterward so the graph stays current. Requires a connected " +
|
|
1568
|
+
"LinkedIn account (Integrations); it says so if none is connected.",
|
|
1569
|
+
{
|
|
1570
|
+
text: z.string().min(1).describe("The message body to send."),
|
|
1571
|
+
linkedin_url: z.string().optional().describe("The recipient's LinkedIn profile URL. Provide this OR linkedin_member_id (to start a new chat), OR chat_id (to reply in an existing thread)."),
|
|
1572
|
+
linkedin_member_id: z.string().optional().describe("The recipient's LinkedIn member id, if you have it instead of a profile URL."),
|
|
1573
|
+
chat_id: z.string().optional().describe("An existing conversation's chat_id — pass it to reply in-thread instead of opening a new chat."),
|
|
1574
|
+
},
|
|
1575
|
+
async ({ text, linkedin_url, linkedin_member_id, chat_id }) => {
|
|
1576
|
+
if (!linkedin_url && !linkedin_member_id && !chat_id) {
|
|
1577
|
+
return { content: [{ type: "text", text:
|
|
1578
|
+
"Tell me who to message: pass linkedin_url or linkedin_member_id to start a new chat, or chat_id to reply in an existing thread." }] };
|
|
1579
|
+
}
|
|
1580
|
+
try {
|
|
1581
|
+
const r = await post("/api/linkedin/send-message", { text, linkedin_url, linkedin_member_id, chat_id });
|
|
1582
|
+
return { content: [{ type: "text", text:
|
|
1583
|
+
`Message sent over LinkedIn.${r.chat_id ? ` Thread chat_id: ${r.chat_id} (reuse it to reply in-thread).` : ""}` }] };
|
|
1584
|
+
} catch (e) {
|
|
1585
|
+
const msg =
|
|
1586
|
+
/linkedin_not_connected/.test(e.message)
|
|
1587
|
+
? "No LinkedIn account is connected. Tell the user to connect LinkedIn in Integrations first."
|
|
1588
|
+
: /auth_required/.test(e.message)
|
|
1589
|
+
? "This API key isn't scoped to a workspace, or LinkedIn isn't connected."
|
|
1590
|
+
: /missing_params/.test(e.message)
|
|
1591
|
+
? "Couldn't tell who to message — provide linkedin_url, linkedin_member_id, or chat_id."
|
|
1592
|
+
: `Couldn't send the message: ${e.message}`;
|
|
1593
|
+
return { content: [{ type: "text", text: msg }] };
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
);
|
|
1597
|
+
|
|
1681
1598
|
// ===========================================================================
|
|
1682
1599
|
// TOOL: get_routing_preferences
|
|
1683
1600
|
// The routing preferences that make THIS agent default to Nous for GTM. The
|