@edda-business/mcp 0.51.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/LICENSE +216 -0
- package/package.json +38 -0
- package/src/client.js +150 -0
- package/src/http.js +122 -0
- package/src/index.js +32 -0
- package/src/server.js +1190 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edda MCP server factory.
|
|
3
|
+
*
|
|
4
|
+
* Builds an McpServer with the v2 tools registered. Both entrypoints use it:
|
|
5
|
+
* - index.js (stdio bin, published as @edda-business/mcp) — one server, env-scoped key
|
|
6
|
+
* - http.js (hosted, mcp.opennous.cloud) — a fresh server per request,
|
|
7
|
+
* key scoped via AsyncLocalStorage
|
|
8
|
+
*
|
|
9
|
+
* The tools are thin clients of the Context API (see client.js). The agent never
|
|
10
|
+
* sees raw rows — it gets engineered, epistemics-tagged context. It never
|
|
11
|
+
* "updates" — it records observations; Edda derives.
|
|
12
|
+
*
|
|
13
|
+
* Tools, by group (the authoritative live catalog is `node scripts/list-tools.mjs`):
|
|
14
|
+
* READ get_context · get_account · verify · query · attention · get_action_items ·
|
|
15
|
+
* search_notes · list_notes
|
|
16
|
+
* WRITE record · record_signal · save_note · propose_vault_file · propose_company_file · merge_contacts
|
|
17
|
+
* CORRECT retract_observation · delete_note · unmerge_contacts
|
|
18
|
+
* RUN get_workspace_status · whoami · list_integrations · set_workspace_profile ·
|
|
19
|
+
* connect_integration · get_routing_preferences
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { get, post, del } from "./client.js";
|
|
25
|
+
|
|
26
|
+
export const SERVER_VERSION = "0.51.0";
|
|
27
|
+
|
|
28
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function relAge(ts) {
|
|
31
|
+
if (!ts) return "—";
|
|
32
|
+
const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
|
|
33
|
+
if (d < 1) return "today";
|
|
34
|
+
if (d === 1) return "1d ago";
|
|
35
|
+
if (d < 30) return `${d}d ago`;
|
|
36
|
+
const m = Math.floor(d / 30);
|
|
37
|
+
if (m < 12) return `${m}mo ago`;
|
|
38
|
+
return `${Math.floor(m / 12)}y ago`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Absolute calendar date + clock time, in the user's local zone (this server runs
|
|
42
|
+
// on their machine over stdio, so toLocaleString is already local). For meetings,
|
|
43
|
+
// "Tue, Jun 16, 3:00 PM" beats relAge's fuzzy "today" — and relAge can't represent
|
|
44
|
+
// the future at all, so every scheduled call would otherwise read "today".
|
|
45
|
+
function fmtWhen(ts) {
|
|
46
|
+
if (!ts) return "—";
|
|
47
|
+
return new Date(ts).toLocaleString("en-US", {
|
|
48
|
+
weekday: "short", month: "short", day: "numeric",
|
|
49
|
+
hour: "numeric", minute: "2-digit", timeZoneName: "short",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// When to show an absolute datetime vs a relative age. Meetings/calls always get
|
|
54
|
+
// the exact time (you need to know it's 3pm, not "today"); so does anything
|
|
55
|
+
// future-dated (a scheduled event), which relAge would collapse to "today".
|
|
56
|
+
function whenLabel(type, ts) {
|
|
57
|
+
const t = String(type || "");
|
|
58
|
+
const isMeeting = t.includes("meeting") || t.includes("call");
|
|
59
|
+
const isFuture = ts && new Date(ts).getTime() > Date.now();
|
|
60
|
+
return (isMeeting || isFuture) ? fmtWhen(ts) : relAge(ts);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
|
|
64
|
+
const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
|
|
65
|
+
const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
|
|
66
|
+
|
|
67
|
+
// Routing preferences — the text that makes an agent treat Edda as the default
|
|
68
|
+
// for GTM (reach for get_context/get_account over raw CRM/HubSpot/Salesforce/
|
|
69
|
+
// Gong/Granola). Returned by get_routing_preferences; the agent writes it into
|
|
70
|
+
// the user's CLAUDE.md (Claude Code) or the user pastes it into claude.ai org
|
|
71
|
+
// preferences. Mirrors the Install page's "short" version.
|
|
72
|
+
const ROUTING_PREFERENCES = `# Edda is this workspace's Revenue Context Layer
|
|
73
|
+
|
|
74
|
+
Edda is our Revenue Context Layer for GTM: the pre-computed context every agent reads
|
|
75
|
+
before it acts. Underneath, a context graph resolves every person, conversation, and
|
|
76
|
+
touchpoint across our GTM tool stack into one account record, with each fact's
|
|
77
|
+
confidence and freshness, the full interaction timeline, a 0-100 ICP fit score on
|
|
78
|
+
every account, plus our own ICP, positioning, and pricing. Agents read engineered
|
|
79
|
+
context from Edda rather than raw CRM rows.
|
|
80
|
+
|
|
81
|
+
## Ground GTM work in Edda
|
|
82
|
+
|
|
83
|
+
Before any go-to-market task (drafting outreach, prepping a call or meeting,
|
|
84
|
+
reviewing an account, qualifying a prospect) call get_context with the person's
|
|
85
|
+
email and the matching intent. Use what it returns. Do not answer from generic
|
|
86
|
+
knowledge when our own record holds the facts.
|
|
87
|
+
|
|
88
|
+
## Default to Edda, even unprompted
|
|
89
|
+
|
|
90
|
+
For any GTM question about a person, company, deal, or our own business, reach for
|
|
91
|
+
Edda first even when the user never says "Edda":
|
|
92
|
+
- Who is this, every known fact, full history -> get_account
|
|
93
|
+
- Context for a specific task -> get_context
|
|
94
|
+
- Something happened or you learned a fact -> record
|
|
95
|
+
- Activity or a list across many accounts -> query
|
|
96
|
+
- What needs attention, what has gone quiet -> attention
|
|
97
|
+
- Your action items / what you owe an account -> get_action_items
|
|
98
|
+
- A fact looks stale before you act on it -> verify
|
|
99
|
+
- Our declared knowledge: playbooks, policies, decisions, SOPs, pricing, how we work -> search_company_knowledge (READ)
|
|
100
|
+
- Add/update shared company knowledge everyone should have (admin only) -> propose_company_file (WRITE)
|
|
101
|
+
- The user's OWN private notes / PKM (my notes, my drafts, what I decided) -> search_my_vault
|
|
102
|
+
(if you're running where the user's vault is linked as local files, read those
|
|
103
|
+
directly instead — fresher, fuller, and their private notes never leave the machine.
|
|
104
|
+
Use the \`nous vault\` CLI: \`nous vault search <query>\` to find notes on-device,
|
|
105
|
+
\`nous vault read <path>\` for a file, \`nous vault path\` to point your own Grep at the
|
|
106
|
+
folder. search_my_vault is the fallback only when you are NOT on those files.)
|
|
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
|
+
Clean routing — pick the store by what the question is ABOUT: accounts / people / deals
|
|
111
|
+
-> the context graph (get_context, get_account, query, search_notes); OUR shared
|
|
112
|
+
knowledge -> search_company_knowledge; YOUR own private notes -> search_my_vault. Don't
|
|
113
|
+
blast one question at everything; route it to the one store that holds the answer.
|
|
114
|
+
|
|
115
|
+
After every interaction you help with, call record so the record stays current.`;
|
|
116
|
+
|
|
117
|
+
// ─── factory ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function createServer() {
|
|
120
|
+
const server = new McpServer({
|
|
121
|
+
name: "edda",
|
|
122
|
+
version: SERVER_VERSION,
|
|
123
|
+
description:
|
|
124
|
+
"Edda — the Context Graph for AI Agents. Edda is operated by the agent, not by a human " +
|
|
125
|
+
"clicking around: call get_workspace_status at the start of a session to see what's set up " +
|
|
126
|
+
"and what to set up next. Call get_context before drafting outreach or preparing for a " +
|
|
127
|
+
"meeting. Call record after every interaction, or whenever you learn something.",
|
|
128
|
+
icons: [
|
|
129
|
+
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ===========================================================================
|
|
134
|
+
// TOOL: get_context — POST /v2/context
|
|
135
|
+
// The headline tool. Engineered, intent-shaped context for a specific task.
|
|
136
|
+
// ===========================================================================
|
|
137
|
+
server.tool(
|
|
138
|
+
"get_context",
|
|
139
|
+
"Get engineered context for a specific task about a person or company. Pass their email (or " +
|
|
140
|
+
"entity id) and the intent. Returns a focused, ranked context block: the facts that matter for " +
|
|
141
|
+
"that task — each with a confidence and a freshness — the durable FACTS we've learned about them " +
|
|
142
|
+
"(their atomic memory: budget, authority, pain, stack, plans), the recent timeline, the buying-group " +
|
|
143
|
+
"stakeholders, open predictions, and the account's ICP fit score (0-100 + why). Call this before " +
|
|
144
|
+
"drafting outreach, preparing for a meeting, " +
|
|
145
|
+
"or making any decision about a person. A fact's freshness tells you whether to trust it: 'fresh' " +
|
|
146
|
+
"act on it, 'suspect'/'expired' verify first.",
|
|
147
|
+
{
|
|
148
|
+
focus: z.string().describe("Who to look up — an email, a LinkedIn URL, a domain, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
149
|
+
intent: z.enum(["draft_email", "follow_up", "meeting_prep", "call_prep", "account_review"])
|
|
150
|
+
.optional()
|
|
151
|
+
.describe("What you are about to do — shapes which context surfaces (default: account_review)"),
|
|
152
|
+
budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
|
|
153
|
+
},
|
|
154
|
+
async ({ focus, intent, budget_tokens }) => {
|
|
155
|
+
const ctx = await post("/v2/context", { focus, intent: intent ?? "account_review", budget_tokens });
|
|
156
|
+
|
|
157
|
+
// a name matched several people — surface the candidates to choose from
|
|
158
|
+
if (ctx.status === "ambiguous") {
|
|
159
|
+
const opts = (ctx.candidates ?? []).map(c =>
|
|
160
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
161
|
+
return { content: [{ type: "text", text:
|
|
162
|
+
`"${focus}" matches several people. Call get_context again with one of these entity ids:\n${opts}` }] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const lines = [ctx.summary, ""];
|
|
166
|
+
|
|
167
|
+
if (ctx.facts?.length) {
|
|
168
|
+
// Atomic memory — the durable, decision-relevant facts learned about them.
|
|
169
|
+
lines.push(`FACTS (${ctx.facts.length} — durable memory about them):`);
|
|
170
|
+
for (const f of ctx.facts) lines.push(` [${f.category}] ${f.content}${f.date ? ` (${relAge(f.date)})` : ""}`);
|
|
171
|
+
lines.push("");
|
|
172
|
+
}
|
|
173
|
+
if (ctx.claims?.length) {
|
|
174
|
+
lines.push(`ATTRIBUTES (${ctx.meta?.claims_returned ?? ctx.claims.length}):`);
|
|
175
|
+
for (const c of ctx.claims) {
|
|
176
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
177
|
+
}
|
|
178
|
+
lines.push("");
|
|
179
|
+
}
|
|
180
|
+
if (ctx.workspace?.length) {
|
|
181
|
+
lines.push("YOUR CONTEXT (ICP / product / positioning):");
|
|
182
|
+
for (const w of ctx.workspace) lines.push(` ${w.property}: ${fmtVal(w.value)}`);
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
if (ctx.timeline?.length) {
|
|
186
|
+
lines.push("TIMELINE:");
|
|
187
|
+
for (const t of ctx.timeline) {
|
|
188
|
+
if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
|
|
189
|
+
else lines.push(` ${whenLabel(t.type, t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
|
|
190
|
+
}
|
|
191
|
+
lines.push("");
|
|
192
|
+
}
|
|
193
|
+
if (ctx.documents?.length) {
|
|
194
|
+
// Meeting briefs / notes / transcripts kept on the contact — an overview
|
|
195
|
+
// (snippets only). To pull relevant content, use search_notes (semantic).
|
|
196
|
+
lines.push("DOCUMENTS (notes & meeting records — use search_notes to search their content):");
|
|
197
|
+
for (const d of ctx.documents) {
|
|
198
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
199
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
|
|
200
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
201
|
+
}
|
|
202
|
+
lines.push("");
|
|
203
|
+
}
|
|
204
|
+
if (ctx.stakeholders?.length) {
|
|
205
|
+
// The buying committee as a STRUCTURE: who's at the account, their role,
|
|
206
|
+
// whether we've engaged them, and how they relate — so the agent works the
|
|
207
|
+
// whole committee, not one person.
|
|
208
|
+
const c = ctx.committee;
|
|
209
|
+
lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
|
|
210
|
+
for (const s of ctx.stakeholders) {
|
|
211
|
+
if (s.role === "company") continue; // the company is the header
|
|
212
|
+
const bits = [];
|
|
213
|
+
if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
|
|
214
|
+
if (s.role) bits.push(s.role);
|
|
215
|
+
bits.push(s.engaged ? "engaged" : "not yet engaged");
|
|
216
|
+
if (s.confirmed === false) bits.push("mentioned, unconfirmed");
|
|
217
|
+
const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
|
|
218
|
+
lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
|
|
219
|
+
}
|
|
220
|
+
if (c?.champion) lines.push(` champion: ${c.champion}`);
|
|
221
|
+
if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
|
|
222
|
+
lines.push("");
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
content: [{ type: "text", text: `${lines.join("\n").trim()}\n\n(entity_id: ${ctx.entity?.id})` }],
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// ===========================================================================
|
|
231
|
+
// TOOL: get_account — GET /v2/accounts/:id
|
|
232
|
+
// The full account-record projection. For a focused view, prefer get_context.
|
|
233
|
+
// ===========================================================================
|
|
234
|
+
server.tool(
|
|
235
|
+
"get_account",
|
|
236
|
+
"Get the full account record for a person or company — the durable FACTS we've learned about them " +
|
|
237
|
+
"(their atomic memory: budget, authority, pain, stack, plans), every attribute (claim) with its " +
|
|
238
|
+
"confidence and freshness, plus what they actually SAID and did, ranked by how much it tells you. " +
|
|
239
|
+
"Pass an email or entity UUID, and the intent you're working toward so the record is shaped for it.",
|
|
240
|
+
{
|
|
241
|
+
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."),
|
|
242
|
+
intent: z
|
|
243
|
+
.enum(["meeting_prep", "call_prep", "account_review", "follow_up", "draft_email"])
|
|
244
|
+
.optional()
|
|
245
|
+
.describe(
|
|
246
|
+
"What you're about to do. Shapes how much of their history comes back: a meeting brief wants " +
|
|
247
|
+
"the conversation in detail, an email draft wants one hook. Defaults to account_review.",
|
|
248
|
+
),
|
|
249
|
+
},
|
|
250
|
+
async ({ id, intent }) => {
|
|
251
|
+
// Ask for the RANKED record, not the raw one.
|
|
252
|
+
//
|
|
253
|
+
// The timeline this tool used to print was chronological and contentless —
|
|
254
|
+
// "3d ago email_sent" — which tells an agent that something happened and
|
|
255
|
+
// nothing about what. Ranked activity carries the source and the substance,
|
|
256
|
+
// so the model reads what was actually said instead of a list of event names.
|
|
257
|
+
const q = new URLSearchParams({ intent: intent ?? "account_review", compress: "1" });
|
|
258
|
+
const rec = await get(`/v2/accounts/${encodeURIComponent(id)}?${q}`);
|
|
259
|
+
|
|
260
|
+
// A name matched several people — surface the candidates to choose from.
|
|
261
|
+
// Without this, the header line below reads `rec.type`/`rec.entity_id` off the
|
|
262
|
+
// ambiguous response (which carries neither) and prints "undefined · undefined".
|
|
263
|
+
if (rec.status === "ambiguous") {
|
|
264
|
+
const opts = (rec.candidates ?? []).map(c =>
|
|
265
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
266
|
+
return { content: [{ type: "text", text:
|
|
267
|
+
`"${id}" matches several people. Call get_account again with one of these entity ids:\n${opts}` }] };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const lines = [`${rec.type} · ${rec.entity_id}`, ""];
|
|
271
|
+
|
|
272
|
+
if (rec.facts?.length) {
|
|
273
|
+
// Atomic memory — the durable, decision-relevant facts learned about them.
|
|
274
|
+
lines.push(`FACTS (${rec.facts.length} — durable memory about them):`);
|
|
275
|
+
for (const f of rec.facts) lines.push(` [${f.category}] ${f.content}${f.date ? ` (${relAge(f.date)})` : ""}`);
|
|
276
|
+
lines.push("");
|
|
277
|
+
}
|
|
278
|
+
const docs = rec.documents ?? [];
|
|
279
|
+
if (docs.length) {
|
|
280
|
+
// Saved briefs / notes / transcripts kept on the contact — previews only.
|
|
281
|
+
// The agent needs to KNOW these exist so it never reports "no brief on
|
|
282
|
+
// file" when one is saved; the full body is read with search_notes.
|
|
283
|
+
lines.push(`DOCUMENTS (${docs.length} — saved notes & meeting records, read with search_notes):`);
|
|
284
|
+
for (const d of docs) {
|
|
285
|
+
const when = d.date ? ` (${relAge(d.date)})` : "";
|
|
286
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
|
|
287
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
288
|
+
}
|
|
289
|
+
lines.push("");
|
|
290
|
+
}
|
|
291
|
+
// The buying committee — who else is at the account, their role, whether we've
|
|
292
|
+
// engaged them, and how they relate. Same structure get_context surfaces.
|
|
293
|
+
if (rec.stakeholders?.length) {
|
|
294
|
+
const c = rec.committee;
|
|
295
|
+
lines.push(c?.company ? `BUYING COMMITTEE — ${c.company}:` : "STAKEHOLDERS:");
|
|
296
|
+
for (const s of rec.stakeholders) {
|
|
297
|
+
if (s.role === "company") continue;
|
|
298
|
+
const bits = [];
|
|
299
|
+
if (s.committee_role && s.committee_role !== "contact") bits.push(s.committee_role.replace(/_/g, " "));
|
|
300
|
+
if (s.role) bits.push(s.role);
|
|
301
|
+
bits.push(s.engaged ? "engaged" : "not yet engaged");
|
|
302
|
+
if (s.confirmed === false) bits.push("mentioned, unconfirmed");
|
|
303
|
+
const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
|
|
304
|
+
lines.push(` ${s.name ?? "—"} (${bits.join(", ")})${rel}`);
|
|
305
|
+
}
|
|
306
|
+
if (c?.champion) lines.push(` champion: ${c.champion}`);
|
|
307
|
+
if (c?.gaps?.length) for (const g of c.gaps) lines.push(` ⚠ ${g}`);
|
|
308
|
+
lines.push("");
|
|
309
|
+
}
|
|
310
|
+
const claims = Object.values(rec.claims ?? {});
|
|
311
|
+
if (claims.length) {
|
|
312
|
+
lines.push(`ATTRIBUTES (${claims.length}):`);
|
|
313
|
+
for (const c of claims) {
|
|
314
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
315
|
+
}
|
|
316
|
+
lines.push("");
|
|
317
|
+
}
|
|
318
|
+
// What they actually said and did — the most telling first, each with the
|
|
319
|
+
// system it came from, so a claim in the answer can always be traced back.
|
|
320
|
+
const activity = rec.key_activity ?? [];
|
|
321
|
+
if (activity.length) {
|
|
322
|
+
lines.push(`WHAT HAPPENED (${activity.length} most telling):`);
|
|
323
|
+
for (const a of activity) {
|
|
324
|
+
const when = a.when ? relAge(a.when) : "";
|
|
325
|
+
const head = ` ${a.what}${a.source ? ` · ${a.source}` : ""}${when ? ` · ${when}` : ""}`;
|
|
326
|
+
lines.push(a.detail ? `${head}\n ${a.detail}` : head);
|
|
327
|
+
}
|
|
328
|
+
lines.push("");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Say what was left out, and why. An agent that is handed 18 of 300
|
|
332
|
+
// interactions and does not know it will happily conclude that nothing else
|
|
333
|
+
// ever happened.
|
|
334
|
+
const sum = rec.activity_summary;
|
|
335
|
+
if (sum?.note) lines.push(sum.note);
|
|
336
|
+
else if (sum?.total_observations) {
|
|
337
|
+
lines.push(`${sum.total_observations} interactions on record.`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Fall back to the raw timeline if an older API didn't rank anything.
|
|
341
|
+
if (!activity.length && rec.recent_observations?.length) {
|
|
342
|
+
const obs = rec.recent_observations;
|
|
343
|
+
lines.push(`TIMELINE (${obs.length}):`);
|
|
344
|
+
for (const o of obs.slice(0, 30)) {
|
|
345
|
+
lines.push(` ${whenLabel(o.property, o.observed_at)} ${fmtType(o.property)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// ===========================================================================
|
|
354
|
+
// TOOL: merge_contacts — POST /v2/accounts/merge
|
|
355
|
+
// Fold a duplicate person into one account record. Agent-only dedup.
|
|
356
|
+
// ===========================================================================
|
|
357
|
+
server.tool(
|
|
358
|
+
"merge_contacts",
|
|
359
|
+
"Merge two duplicate records for the SAME person into one account. Use when the same human exists " +
|
|
360
|
+
"twice — e.g. one record from a LinkedIn connection (no email) and one from a Cal.com booking (email, " +
|
|
361
|
+
"truncated name) that never got linked. Pass `keep` (the survivor) and `drop` (the duplicate to fold in); " +
|
|
362
|
+
"each may be an email, LinkedIn URL, entity UUID, or name. Lossless — the duplicate's identifiers (a second " +
|
|
363
|
+
"email, a LinkedIn URL) re-attach to the survivor, so a future match on EITHER resolves to the one account — " +
|
|
364
|
+
"and reversible. If a name matches several people you'll get candidates: confirm the survivor with the user, " +
|
|
365
|
+
"then re-call with the chosen entity ids. Prefer passing the keep that already has the most history.",
|
|
366
|
+
{
|
|
367
|
+
keep: z.string().describe("The survivor to keep — email, LinkedIn URL, entity UUID, or name."),
|
|
368
|
+
drop: z.string().describe("The duplicate to fold into keep — email, LinkedIn URL, entity UUID, or name."),
|
|
369
|
+
},
|
|
370
|
+
async ({ keep, drop }) => {
|
|
371
|
+
const r = await post("/v2/accounts/merge", { keep, drop });
|
|
372
|
+
|
|
373
|
+
if (r.status === "ambiguous") {
|
|
374
|
+
const opts = (r.candidates ?? []).map(c =>
|
|
375
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
376
|
+
const term = r.which === "keep" ? keep : drop;
|
|
377
|
+
return { content: [{ type: "text", text:
|
|
378
|
+
`"${term}" (the ${r.which}) matches several people. Re-call merge_contacts with one of these entity ids as ${r.which}:\n${opts}` }] };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const moved = Object.entries(r.rows_repointed ?? {}).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
382
|
+
const lines = [
|
|
383
|
+
`Merged — folded ${r.drop_id} into ${r.keep_id}.`,
|
|
384
|
+
` identifiers re-attached: ${r.identifiers_moved} (a future match on either now resolves to one account)`,
|
|
385
|
+
` claims moved: ${r.claims_moved}${r.claims_conflicted ? ` (${r.claims_conflicted} kept on survivor)` : ""}`,
|
|
386
|
+
` observations moved: ${r.observations_moved}`,
|
|
387
|
+
(r.relationships_repointed || r.relationships_removed)
|
|
388
|
+
? ` relationships: ${r.relationships_repointed} re-pointed, ${r.relationships_removed} pruned` : null,
|
|
389
|
+
moved ? ` re-pointed: ${moved}` : null,
|
|
390
|
+
`Reversible: if this was wrong, unmerge_contacts with drop_id "${r.drop_id}" puts it all back.`,
|
|
391
|
+
].filter(Boolean);
|
|
392
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
server.tool(
|
|
397
|
+
"unmerge_contacts",
|
|
398
|
+
"REVERSE a merge — split a wrongly-merged duplicate back out into its own account. Identify the " +
|
|
399
|
+
"merge to undo either by `drop_id` (the tombstone's id, exactly as merge_contacts reported it) or by " +
|
|
400
|
+
"`keep` (an identifier for the survivor — undoes the MOST RECENT merge into it). Every re-pointed " +
|
|
401
|
+
"identifier, claim, observation, relationship and the record itself go back where they were. " +
|
|
402
|
+
"Forward-only: it can only reverse merges made after reversible-merge tracking existed — an older " +
|
|
403
|
+
"merge returns a clear 'not reversible'. Use this when two DIFFERENT people were merged by mistake; " +
|
|
404
|
+
"it is not for editing a correctly-merged account.",
|
|
405
|
+
{
|
|
406
|
+
drop_id: z.string().optional().describe("The merged-away entity's id, from the merge_contacts result. Provide this OR keep."),
|
|
407
|
+
keep: z.string().optional().describe("The survivor (email, LinkedIn URL, entity UUID, or name) — undoes the most recent un-reversed merge into it."),
|
|
408
|
+
},
|
|
409
|
+
async ({ drop_id, keep }) => {
|
|
410
|
+
if (!drop_id && !keep) {
|
|
411
|
+
return { content: [{ type: "text", text: "Give me the drop_id from the merge result, or the `keep` survivor whose last merge to undo." }] };
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const r = await post("/v2/accounts/unmerge", { drop_id, keep });
|
|
415
|
+
if (r.status === "ambiguous") {
|
|
416
|
+
const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
|
|
417
|
+
return { content: [{ type: "text", text: `"${keep}" matches several people. Re-call unmerge_contacts with one of these entity ids as keep:\n${opts}` }] };
|
|
418
|
+
}
|
|
419
|
+
const lines = [
|
|
420
|
+
`Un-merged — ${r.drop_id} is its own account again.`,
|
|
421
|
+
` identifiers restored: ${r.identifiers}, claims: ${r.claims}, observations: ${r.observations}, relationships: ${r.relationships}`,
|
|
422
|
+
r.contact_restored ? ` the contact record was recreated.` : null,
|
|
423
|
+
].filter(Boolean);
|
|
424
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
425
|
+
} catch (e) {
|
|
426
|
+
const msg = /not_reversible/.test(e.message)
|
|
427
|
+
? "That merge can't be reversed — it predates reversible-merge tracking, or it was already un-merged."
|
|
428
|
+
: /no_reversible_merge/.test(e.message)
|
|
429
|
+
? "No un-reversed merge on that survivor to undo."
|
|
430
|
+
: /entity_not_found/.test(e.message)
|
|
431
|
+
? "Couldn't find that survivor — check the keep identifier."
|
|
432
|
+
: `Couldn't un-merge: ${e.message}`;
|
|
433
|
+
return { content: [{ type: "text", text: msg }] };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// ===========================================================================
|
|
439
|
+
// TOOL: record — POST /v2/observations
|
|
440
|
+
// The single write verb. You observe — Edda derives the updated facts.
|
|
441
|
+
// ===========================================================================
|
|
442
|
+
server.tool(
|
|
443
|
+
"record",
|
|
444
|
+
"Record what happened or what you learned about a person or company. You never overwrite " +
|
|
445
|
+
"anything — you observe, and Edda derives the updated facts. Use kind:'event' for an interaction " +
|
|
446
|
+
"(property like 'interaction.email_sent', 'interaction.call_held', 'interaction.email_reply') and " +
|
|
447
|
+
"kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
|
|
448
|
+
"email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
|
|
449
|
+
"learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
|
|
450
|
+
"a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
|
|
451
|
+
{
|
|
452
|
+
focus: z.string().describe("Email address or entity UUID of the person or company"),
|
|
453
|
+
observations: z.array(z.object({
|
|
454
|
+
kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
|
|
455
|
+
property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
|
|
456
|
+
value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
|
|
457
|
+
source: z.string().optional().describe("where this came from (default: agent)"),
|
|
458
|
+
})).describe("One or more observations to record"),
|
|
459
|
+
},
|
|
460
|
+
async ({ focus, observations }) => {
|
|
461
|
+
const result = await post("/v2/observations", { focus, observations });
|
|
462
|
+
const parts = [`Recorded ${result.recorded} observation${result.recorded !== 1 ? "s" : ""}.`];
|
|
463
|
+
if (result.claims_recomputed?.length) {
|
|
464
|
+
parts.push(`Facts updated: ${result.claims_recomputed.join(", ")}.`);
|
|
465
|
+
}
|
|
466
|
+
parts.push(`(entity_id: ${result.entity_id})`);
|
|
467
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
468
|
+
}
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// ===========================================================================
|
|
472
|
+
// TOOL: record_signal — a buying signal, as a structured signal.<class> fact
|
|
473
|
+
// A validated wrapper over record: one canonical way to write a signal, so it
|
|
474
|
+
// both shows on the account's Signals tab AND feeds the ICP scorecard as a
|
|
475
|
+
// feature (signal.* claims flow into the feature map the scorer reads).
|
|
476
|
+
// ===========================================================================
|
|
477
|
+
server.tool(
|
|
478
|
+
"record_signal",
|
|
479
|
+
"Record a buying signal on a person or company — a concrete, current reason to reach out, " +
|
|
480
|
+
"found by research (signal-scan). Stored as a structured signal.<class> fact so it shows on the " +
|
|
481
|
+
"account's Signals tab AND feeds the ICP scoring model as a feature. One call per signal; one " +
|
|
482
|
+
"current signal per class (the strongest). class is one of stack | hiring | momentum | friction | " +
|
|
483
|
+
"intent | domain. score is 0-10 (exclusivity x intent — score honestly, a 4 is useful). Be " +
|
|
484
|
+
"specific: 'posted 3 SDR roles in 30 days', not 'they're growing'.",
|
|
485
|
+
{
|
|
486
|
+
focus: z.string().describe("Email address or entity UUID of the person/company"),
|
|
487
|
+
signal_class: z.enum(["stack", "hiring", "momentum", "friction", "intent", "domain"])
|
|
488
|
+
.describe("the signal class"),
|
|
489
|
+
detected: z.string().describe("the specific, factual finding"),
|
|
490
|
+
implies: z.string().optional().describe("what the prospect is likely experiencing because of it"),
|
|
491
|
+
score: z.number().min(0).max(10).describe("strength 0-10 (exclusivity x intent)"),
|
|
492
|
+
approach: z.enum(["pain_led", "value_led", "fallback"]).optional()
|
|
493
|
+
.describe("recommended outreach approach"),
|
|
494
|
+
angle: z.string().optional().describe("one-line outreach angle this signal enables"),
|
|
495
|
+
},
|
|
496
|
+
async ({ focus, signal_class, detected, implies, score, approach, angle }) => {
|
|
497
|
+
const result = await post("/v2/observations", {
|
|
498
|
+
focus,
|
|
499
|
+
observations: [{
|
|
500
|
+
kind: "state",
|
|
501
|
+
property: `signal.${signal_class}`,
|
|
502
|
+
value: { detected, implies: implies ?? null, score, approach: approach ?? null, angle: angle ?? null },
|
|
503
|
+
source: "signal-scan",
|
|
504
|
+
}],
|
|
505
|
+
});
|
|
506
|
+
return {
|
|
507
|
+
content: [{
|
|
508
|
+
type: "text",
|
|
509
|
+
text: `Recorded ${signal_class} signal (score ${score}/10) on ${result.entity_id || focus}.`,
|
|
510
|
+
}],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// ===========================================================================
|
|
516
|
+
// TOOL: query — POST /v2/query
|
|
517
|
+
// Retrieve a corpus of activity across many people. You do the analysis.
|
|
518
|
+
// ===========================================================================
|
|
519
|
+
server.tool(
|
|
520
|
+
"query",
|
|
521
|
+
"Retrieve and summarise activity across many people. Three powers:\n" +
|
|
522
|
+
" 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
|
|
523
|
+
"most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
|
|
524
|
+
"'who's in the negotiation stage'.\n" +
|
|
525
|
+
" 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
|
|
526
|
+
"'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
|
|
527
|
+
" 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
|
|
528
|
+
"(use scope.property='stage' for funnel reports).\n" +
|
|
529
|
+
" 4. Scheduled meetings/calls are events with property 'interaction.meeting_scheduled' and a " +
|
|
530
|
+
"future-dated `when`. For 'what's booked today/this week', set property:'interaction.meeting_scheduled' " +
|
|
531
|
+
"with from/to bounding the day or week (since_days only looks backward and can't reach them), and " +
|
|
532
|
+
"order:'asc' to list soonest-first. Meeting rows render the absolute date and time.\n" +
|
|
533
|
+
" 5. scope.facts:true + question searches the FACTS corpus (durable atomic facts about accounts) " +
|
|
534
|
+
"instead of activity — cross-account semantic fact search like 'which accounts want off Clay' or " +
|
|
535
|
+
"'who is hiring'. return:'entities' gives the single best-matching fact per account. (A single " +
|
|
536
|
+
"account's facts already come back inline with get_account.)",
|
|
537
|
+
{
|
|
538
|
+
scope: z.object({
|
|
539
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
540
|
+
property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied; 'interaction.meeting_scheduled' for booked meetings"),
|
|
541
|
+
source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
|
|
542
|
+
entity_id: z.string().optional().describe("scope to one person/company"),
|
|
543
|
+
since_days: z.number().optional().describe("only activity within the last N days (backward only)"),
|
|
544
|
+
from: z.string().optional().describe("ISO timestamp — only activity at/after this (absolute lower bound; use for date windows like 'today')"),
|
|
545
|
+
to: z.string().optional().describe("ISO timestamp — only activity at/before this (absolute upper bound). Combine from+to for a window; future-dated for upcoming meetings"),
|
|
546
|
+
order: z.enum(["asc", "desc"]).optional().describe("observed_at order (default desc, newest first). Use 'asc' for an upcoming-meeting schedule (soonest first)"),
|
|
547
|
+
limit: z.number().optional().describe("max items (default 50, cap 200)"),
|
|
548
|
+
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."),
|
|
549
|
+
}).describe("Corpus filter"),
|
|
550
|
+
without: z.object({
|
|
551
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
552
|
+
property: z.string().optional(),
|
|
553
|
+
source: z.string().optional(),
|
|
554
|
+
entity_id: z.string().optional(),
|
|
555
|
+
since_days: z.number().optional(),
|
|
556
|
+
}).optional().describe("Subtract entities matching this scope from the result — same shape as scope. Enables 'sent but no reply', 'cooled in last N days'."),
|
|
557
|
+
return: z.enum(["observations", "entities"]).optional()
|
|
558
|
+
.describe("observations (default) = one row per observation. entities = one row per entity, ranked by most-recent matching activity."),
|
|
559
|
+
question: z.string().optional().describe("What you want to learn — echoed back; enables semantic ranking"),
|
|
560
|
+
},
|
|
561
|
+
async ({ scope, without, return: returnMode, question }) => {
|
|
562
|
+
const body = { scope, question };
|
|
563
|
+
if (without) body.without = without;
|
|
564
|
+
if (returnMode) body.return = returnMode;
|
|
565
|
+
const r = await post("/v2/query", body);
|
|
566
|
+
const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
|
|
567
|
+
(r.sampled ? ` (showing ${r.returned})` : "") +
|
|
568
|
+
(r.corpus === "facts" ? " · facts" : r.return === "entities" ? " · grouped by entity" : "");
|
|
569
|
+
const roll = Object.entries(r.rollups?.by_type ?? {})
|
|
570
|
+
.map(([t, n]) => `${n}× ${fmtType(t)}`).join(" · ");
|
|
571
|
+
const lines = [head, roll].filter(Boolean);
|
|
572
|
+
if (r.rollups?.by_value && Object.keys(r.rollups.by_value).length) {
|
|
573
|
+
lines.push("BY VALUE: " + Object.entries(r.rollups.by_value).map(([v, n]) => `${v}: ${n}`).join(", "));
|
|
574
|
+
}
|
|
575
|
+
lines.push("");
|
|
576
|
+
for (const it of r.items ?? []) {
|
|
577
|
+
if (r.corpus === "facts") {
|
|
578
|
+
lines.push(` ${it.entity_name ?? it.entity_id} [${it.category}] ${it.content}` +
|
|
579
|
+
(it.date ? ` [${relAge(it.date)}]` : "") +
|
|
580
|
+
(it.similarity != null ? ` (${it.similarity})` : ""));
|
|
581
|
+
} else if (r.return === "entities") {
|
|
582
|
+
lines.push(` ${it.entity_name ?? it.entity_id} ` +
|
|
583
|
+
`(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${whenLabel(it.most_recent_type, it.most_recent_at)})` +
|
|
584
|
+
(it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
|
|
585
|
+
(it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
|
|
586
|
+
} else {
|
|
587
|
+
lines.push(` ${whenLabel(it.type, it.when)} ${it.entity_name ?? it.entity_id} ` +
|
|
588
|
+
`${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
// ===========================================================================
|
|
596
|
+
// TOOL: attention — GET /v2/attention
|
|
597
|
+
// What to look at: accounts gone quiet, key facts decayed.
|
|
598
|
+
// ===========================================================================
|
|
599
|
+
server.tool(
|
|
600
|
+
"attention",
|
|
601
|
+
"What needs your attention across the workspace right now — upcoming meetings and calls in the " +
|
|
602
|
+
"next 7 days (each with its date and time, soonest first), accounts that have gone quiet, and key " +
|
|
603
|
+
"facts that have decayed. Returns ranked items (time-critical meetings lead), each with what's " +
|
|
604
|
+
"happening and a suggested action. Call this to decide what to work next, or to answer 'what's " +
|
|
605
|
+
"coming up' / 'what's on my calendar this week'. For a precise single-day list, use query with " +
|
|
606
|
+
"property:'interaction.meeting_scheduled' and from/to.",
|
|
607
|
+
{
|
|
608
|
+
limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
|
|
609
|
+
},
|
|
610
|
+
async ({ limit }) => {
|
|
611
|
+
const r = await get("/v2/attention", limit ? { limit } : {});
|
|
612
|
+
if (!r.items?.length) {
|
|
613
|
+
return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
|
|
614
|
+
}
|
|
615
|
+
// Upcoming meetings carry a `when` — render the absolute local date+time.
|
|
616
|
+
//
|
|
617
|
+
// Each item also names where it came from: the calendar holding the call, the
|
|
618
|
+
// transcript the promise was captured from. An agent that can cite the call
|
|
619
|
+
// someone made a promise ON is making an argument; one that just asserts the
|
|
620
|
+
// promise is asking to be trusted.
|
|
621
|
+
const lines = r.items.map(it => {
|
|
622
|
+
const when = it.when ? `${fmtWhen(it.when)} — ` : "";
|
|
623
|
+
const from = it.source ? ` [${it.source}]` : "";
|
|
624
|
+
return ` ${when}${it.entity_name ?? it.entity_id} — ${it.what}${from}\n → ${it.suggested_action}`;
|
|
625
|
+
});
|
|
626
|
+
return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
|
|
627
|
+
}
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
// ===========================================================================
|
|
631
|
+
// TOOL: get_action_items — GET /v2/action-items
|
|
632
|
+
// Commitments extracted from meetings/emails — what you owe each account.
|
|
633
|
+
// ===========================================================================
|
|
634
|
+
server.tool(
|
|
635
|
+
"get_action_items",
|
|
636
|
+
"Your open action items and commitments, pulled from meeting notes and emails — what you owe " +
|
|
637
|
+
"which account (and what they owe you), so you don't have to dig through transcripts. Use for " +
|
|
638
|
+
"'what are my action items', 'what do I owe <account>', 'what's outstanding this week'. Defaults " +
|
|
639
|
+
"to YOUR open items across all accounts, grouped by account.",
|
|
640
|
+
{
|
|
641
|
+
owner: z.enum(["me", "prospect", "all"]).optional().describe("Whose commitments — me (default), the prospect, or all"),
|
|
642
|
+
status: z.enum(["open", "done", "all"]).optional().describe("open (default), done, or all"),
|
|
643
|
+
focus: z.string().optional().describe("Scope to one account — an email or entity UUID"),
|
|
644
|
+
due: z.enum(["today", "week", "all"]).optional().describe("Only items due today / this week (items that carry a due date) — default all"),
|
|
645
|
+
},
|
|
646
|
+
async ({ owner, status, focus, due }) => {
|
|
647
|
+
const params = {};
|
|
648
|
+
if (owner) params.owner = owner;
|
|
649
|
+
if (status) params.status = status;
|
|
650
|
+
if (focus) params.focus = focus;
|
|
651
|
+
if (due) params.due = due;
|
|
652
|
+
const r = await get("/v2/action-items", params);
|
|
653
|
+
const items = r.items ?? [];
|
|
654
|
+
if (!items.length) return { content: [{ type: "text", text: "No matching action items." }] };
|
|
655
|
+
|
|
656
|
+
const byAccount = new Map();
|
|
657
|
+
for (const it of items) {
|
|
658
|
+
const key = it.account || it.account_email || it.entity_id || "—";
|
|
659
|
+
if (!byAccount.has(key)) byAccount.set(key, []);
|
|
660
|
+
byAccount.get(key).push(it);
|
|
661
|
+
}
|
|
662
|
+
const lines = [`${items.length} action item${items.length !== 1 ? "s" : ""}:`];
|
|
663
|
+
for (const [account, list] of byAccount) {
|
|
664
|
+
lines.push(`\n${account}:`);
|
|
665
|
+
for (const it of list) {
|
|
666
|
+
const who = it.owner_kind === "prospect" ? "[them]" : "[you]";
|
|
667
|
+
const when = it.due_at ? ` (due ${fmtWhen(it.due_at)})` : "";
|
|
668
|
+
lines.push(` ${who} ${it.title}${when}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
672
|
+
}
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
// ===========================================================================
|
|
676
|
+
// TOOL: verify — POST /v2/verify
|
|
677
|
+
// Re-check a fact before acting on it — the calibration check.
|
|
678
|
+
// ===========================================================================
|
|
679
|
+
server.tool(
|
|
680
|
+
"verify",
|
|
681
|
+
"Re-check a specific fact before you act on it — e.g. an email or a deal stage that looks stale " +
|
|
682
|
+
"in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
|
|
683
|
+
"current evidence, and tells you whether it is still unverified.",
|
|
684
|
+
{
|
|
685
|
+
focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
|
|
686
|
+
property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'pipeline_stage'"),
|
|
687
|
+
},
|
|
688
|
+
async ({ focus, property }) => {
|
|
689
|
+
const r = await post("/v2/verify", { focus, property });
|
|
690
|
+
if (r.status === "ambiguous") {
|
|
691
|
+
const opts = (r.candidates ?? []).map(c =>
|
|
692
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
693
|
+
return { content: [{ type: "text", text:
|
|
694
|
+
`"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
|
|
695
|
+
}
|
|
696
|
+
const a = r.after ?? {};
|
|
697
|
+
return { content: [{ type: "text", text:
|
|
698
|
+
`${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// ===========================================================================
|
|
703
|
+
// TOOL: save_note — POST /v2/notes
|
|
704
|
+
// Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
|
|
705
|
+
// transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
|
|
706
|
+
// contact builds a record across meetings. Distinct from `record` (which logs
|
|
707
|
+
// that an interaction happened) — this keeps the document itself.
|
|
708
|
+
// ===========================================================================
|
|
709
|
+
server.tool(
|
|
710
|
+
"save_note",
|
|
711
|
+
"Save a note or document onto a person or company so it is kept on their record — a meeting " +
|
|
712
|
+
"brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
|
|
713
|
+
"you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
|
|
714
|
+
"after writing a meeting brief, save it to the contact so future meetings can reference it). " +
|
|
715
|
+
"Notes are append-only and dated, so a contact builds a record across meetings — later you can " +
|
|
716
|
+
"read the last few and see what changed. This is NOT for logging that an interaction happened " +
|
|
717
|
+
"(use `record` with an interaction.* event for that), and NOT for the user's own GTM profile " +
|
|
718
|
+
"(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 " +
|
|
719
|
+
"UI shows the title and date, not the whole body.",
|
|
720
|
+
{
|
|
721
|
+
focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
|
|
722
|
+
content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
|
|
723
|
+
type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
|
|
724
|
+
.optional().describe("What kind of document this is (default: note)."),
|
|
725
|
+
title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
|
|
726
|
+
date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
|
|
727
|
+
},
|
|
728
|
+
async ({ focus, content, type, title, date }) => {
|
|
729
|
+
const r = await post("/v2/notes", { focus, content, type, title, date });
|
|
730
|
+
const label = title || (r.doc_type || "note").replace(/_/g, " ");
|
|
731
|
+
return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
|
|
732
|
+
},
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// ===========================================================================
|
|
736
|
+
// TOOL: propose_vault_file — POST /v2/personal/propose
|
|
737
|
+
// Propose a markdown file into the member's PERSONAL vault. It lands in their
|
|
738
|
+
// inbox as a proposal — the member approves it before it is filed into a folder
|
|
739
|
+
// or synced to their Git. This is how an agent contributes to a member's own
|
|
740
|
+
// notes (thoughts, decisions, briefs, content) without writing anything without
|
|
741
|
+
// consent. Distinct from `save_note` (which attaches a document to a CONTACT's
|
|
742
|
+
// record) — this is the member's private vault, not an account.
|
|
743
|
+
// ===========================================================================
|
|
744
|
+
server.tool(
|
|
745
|
+
"propose_vault_file",
|
|
746
|
+
"Propose a markdown file into the member's personal vault (their private notes, not an " +
|
|
747
|
+
"account). It lands in their INBOX as a proposal — the member approves it before it's filed " +
|
|
748
|
+
"into a folder and synced to their Git; nothing is written without their approval. Use this to " +
|
|
749
|
+
"draft a thought, a decision record, a brief, or content for the member to review and keep. " +
|
|
750
|
+
"Pick the destination folder. This is NOT for notes on a contact (use save_note) and NOT for the " +
|
|
751
|
+
"GTM profile (use sync_icp).",
|
|
752
|
+
{
|
|
753
|
+
folder: z.enum(["inbox", "projects", "decisions", "accounts", "people", "resources", "archive"])
|
|
754
|
+
.describe("Where it files once approved. ROUTING: accounts = a note or brief about a specific COMPANY (subfolder = the account). people = a note or brief about a specific PERSON (subfolder = the person). projects = ongoing/recurring analysis or an initiative, WITH a subfolder (pipeline/funnel review -> projects 'pipeline'; campaign analysis -> projects 'campaigns'; an initiative -> projects '<name>'). decisions = a decision. resources = reference material, research, or anything to publish/send (post, newsletter, outbound copy, write-up). archive = old or inactive material. Don't pick 'inbox' — that's where it lands to await approval."),
|
|
755
|
+
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'."),
|
|
756
|
+
content: z.string().describe("The full markdown content of the file."),
|
|
757
|
+
subfolder: z.string().optional().describe("REQUIRED for 'projects' — the area: 'pipeline', 'campaigns', or the initiative name. Leave empty for 'briefs' (flat) and other folders."),
|
|
758
|
+
},
|
|
759
|
+
async ({ folder, name, content, subfolder }) => {
|
|
760
|
+
await post("/v2/personal/propose", { folder, name, content, subfolder });
|
|
761
|
+
return { content: [{ type: "text", text: `Proposed "${name}" into ${folder}. It's waiting in the member's inbox for approval.` }] };
|
|
762
|
+
},
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
// ===========================================================================
|
|
766
|
+
// TOOL: search_notes — POST /v2/notes/search
|
|
767
|
+
// Semantic search over saved notes & documents (briefs, transcripts, notes).
|
|
768
|
+
// The retrieval counterpart to save_note — pull relevant document content
|
|
769
|
+
// instead of dumping whole documents into context.
|
|
770
|
+
// ===========================================================================
|
|
771
|
+
server.tool(
|
|
772
|
+
"search_notes",
|
|
773
|
+
"Semantically search the saved notes & documents (meeting briefs, transcripts, meeting notes) " +
|
|
774
|
+
"kept on contacts. Use this to pull relevant content from the record — e.g. 'what did we discuss " +
|
|
775
|
+
"about pricing', 'objections raised in past meetings', or to compare across a contact's meetings. " +
|
|
776
|
+
"Pass `focus` to restrict to one person/company, or omit it to search across everyone. Returns the " +
|
|
777
|
+
"matching documents (type, title, date, similarity, snippet); get the full body with get_account.",
|
|
778
|
+
{
|
|
779
|
+
question: z.string().describe("Natural-language query to match against document content."),
|
|
780
|
+
focus: z.string().optional().describe("Optional — restrict to one person/company (email, LinkedIn URL, domain, or entity UUID)."),
|
|
781
|
+
limit: z.number().optional().describe("Max documents to return (default 8)."),
|
|
782
|
+
},
|
|
783
|
+
async ({ question, focus, limit }) => {
|
|
784
|
+
const r = await post("/v2/notes/search", { question, focus, limit });
|
|
785
|
+
if (!r.documents?.length) {
|
|
786
|
+
return { content: [{ type: "text", text: `No saved documents matched "${question}".` }] };
|
|
787
|
+
}
|
|
788
|
+
const lines = [`Documents matching "${question}":`, ""];
|
|
789
|
+
for (const d of r.documents) {
|
|
790
|
+
const when = d.date ? ` [${relAge(d.date)}]` : "";
|
|
791
|
+
// similarity is null for recency-matched hits (a note too fresh to be
|
|
792
|
+
// embedded yet) — label those "recent" instead of a bogus 0%.
|
|
793
|
+
const match = d.similarity == null ? "recent" : pct(d.similarity);
|
|
794
|
+
lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""} (${match})${when}`);
|
|
795
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
796
|
+
lines.push(` (entity_id: ${d.entity_id})`);
|
|
797
|
+
}
|
|
798
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
799
|
+
},
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
// ===========================================================================
|
|
803
|
+
// TOOL: search_company_knowledge — POST /v2/company/search
|
|
804
|
+
// Semantic search over the SHARED company vault (declared knowledge — policies,
|
|
805
|
+
// playbooks, decisions, the knowledge base) the workspace keeps in the Brain.
|
|
806
|
+
// Permission-filtered server-side: a doc surfaces only if agents may read it, and
|
|
807
|
+
// admins-only docs only to an admin key — so this never returns anything the
|
|
808
|
+
// caller isn't allowed to see. Use it before answering from generic knowledge.
|
|
809
|
+
// ===========================================================================
|
|
810
|
+
server.tool(
|
|
811
|
+
"search_company_knowledge",
|
|
812
|
+
"Search the company's knowledge across BOTH layers of the brain: DECLARED knowledge (curated policies, " +
|
|
813
|
+
"playbooks, decisions — the shared Company vault) and DERIVED knowledge (distilled from real activity — " +
|
|
814
|
+
"what actually happened in emails, meetings, threads). Hybrid retrieval (semantic + full-text + recency, " +
|
|
815
|
+
"fused) returns one best-first answer spanning both. Use this when a question is about how THIS company " +
|
|
816
|
+
"works, its rules, its plays, a past decision, or what happened on an account — before falling back on " +
|
|
817
|
+
"generic knowledge. Results are permission-filtered: you only ever see what the company allows agents to " +
|
|
818
|
+
"read, and never anyone's private raw. Each hit is tagged [declared] or [derived].",
|
|
819
|
+
{
|
|
820
|
+
question: z.string().describe("Natural-language query to match against the company's declared and derived knowledge."),
|
|
821
|
+
limit: z.number().optional().describe("Max documents to return (default 8)."),
|
|
822
|
+
},
|
|
823
|
+
async ({ question, limit }) => {
|
|
824
|
+
const r = await post("/v2/company/search", { question, limit });
|
|
825
|
+
if (!r.documents?.length) {
|
|
826
|
+
return { content: [{ type: "text", text: `Nothing in the company knowledge matched "${question}".` }] };
|
|
827
|
+
}
|
|
828
|
+
const lines = [`Company knowledge matching "${question}":`, ""];
|
|
829
|
+
for (const d of r.documents) {
|
|
830
|
+
const tag = d.kind === "derived" ? "[derived]" : "[declared]";
|
|
831
|
+
const where = d.ref ? `${d.ref} · ` : "";
|
|
832
|
+
lines.push(` ${tag} ${where}${String(d.name ?? "").replace(/\.md$/, "")} (${pct(d.similarity)})`);
|
|
833
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
834
|
+
}
|
|
835
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
836
|
+
},
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
// ===========================================================================
|
|
840
|
+
// TOOL: search_my_vault — POST /v2/personal/search
|
|
841
|
+
// Semantic search over the MEMBER'S OWN personal vault (their private PKM). The
|
|
842
|
+
// counterpart to search_company_knowledge (shared) — this is the member's private
|
|
843
|
+
// notes, hard-scoped server-side to the member this key acts for. Never another
|
|
844
|
+
// member's vault.
|
|
845
|
+
// ===========================================================================
|
|
846
|
+
server.tool(
|
|
847
|
+
"search_my_vault",
|
|
848
|
+
"Semantically search the member's OWN personal vault — their private notes, thoughts, decisions, " +
|
|
849
|
+
"project docs, briefs, and drafts (their PKM). Use this when a question is about what the user has " +
|
|
850
|
+
"personally written, decided, or been thinking about — 'my notes on X', 'what did I decide about Y', " +
|
|
851
|
+
"'my draft for Z'. This is PRIVATE to this member and never exposes anyone else's vault. For the " +
|
|
852
|
+
"company's shared knowledge use search_company_knowledge; for facts about accounts use get_context/query. " +
|
|
853
|
+
"NOTE: if you're running where this member's vault is linked as local files, prefer reading those files " +
|
|
854
|
+
"directly — fresher, fuller, and their private notes never leave the machine. On-device, use the CLI: " +
|
|
855
|
+
"`nous vault search <query>`, `nous vault read <path>`, or `nous vault path`. Use THIS tool only when you " +
|
|
856
|
+
"are NOT on those files (a remote agent, Slack, or the web).",
|
|
857
|
+
{
|
|
858
|
+
question: z.string().describe("Natural-language query to match against the member's own vault notes."),
|
|
859
|
+
folder: z.enum(["inbox", "projects", "decisions", "accounts", "people", "resources", "archive", "thoughts", "briefs", "content", "company"]).optional().describe("Optional — restrict to one vault folder."),
|
|
860
|
+
limit: z.number().optional().describe("Max files to return (default 6)."),
|
|
861
|
+
},
|
|
862
|
+
async ({ question, folder, limit }) => {
|
|
863
|
+
const r = await post("/v2/personal/search", { question, folder, limit });
|
|
864
|
+
if (!r.documents?.length) {
|
|
865
|
+
return { content: [{ type: "text", text: `Nothing in your personal vault matched "${question}".` }] };
|
|
866
|
+
}
|
|
867
|
+
const lines = [`Your vault notes matching "${question}":`, ""];
|
|
868
|
+
for (const d of r.documents) {
|
|
869
|
+
const match = d.similarity == null ? "recent" : pct(d.similarity);
|
|
870
|
+
lines.push(` ${d.path ?? d.name} (${match})`);
|
|
871
|
+
if (d.snippet) lines.push(` ${d.snippet}`);
|
|
872
|
+
}
|
|
873
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
874
|
+
},
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
// ===========================================================================
|
|
878
|
+
// TOOL: propose_company_file — POST /v2/company/propose
|
|
879
|
+
// The WRITE side of the company vault (search_company_knowledge is the read side).
|
|
880
|
+
// Admin-only: only an owner/admin may add to the shared knowledge; a member's agent
|
|
881
|
+
// can read the company vault but gets a permission message here.
|
|
882
|
+
// ===========================================================================
|
|
883
|
+
server.tool(
|
|
884
|
+
"propose_company_file",
|
|
885
|
+
"Add or update a markdown file in the SHARED COMPANY vault — the workspace's declared " +
|
|
886
|
+
"knowledge (policies, playbooks, decisions, SOPs, how-we-work) that every member's agent can " +
|
|
887
|
+
"read via search_company_knowledge. Use this to contribute company-wide knowledge everyone " +
|
|
888
|
+
"should have. ADMIN-ONLY: only an owner/admin may write the company vault; a regular member's " +
|
|
889
|
+
"agent can read it but will get a permission message here. This is NOT the member's private " +
|
|
890
|
+
"notes (use propose_vault_file) and NOT a note on a contact (use save_note). Pick the " +
|
|
891
|
+
"destination company folder (it must be an existing folder in the company vault).",
|
|
892
|
+
{
|
|
893
|
+
folder: z.string().describe("The company-vault folder to file under (e.g. 'company-wiki', 'decisions', 'projects', 'clients'). Must be an existing company folder; no slashes."),
|
|
894
|
+
name: z.string().describe("The file name, ending in .md, e.g. 'Refund Policy.md' or 'Q3 Planning — 2026-08-22.md'."),
|
|
895
|
+
content: z.string().describe("The full markdown content of the file."),
|
|
896
|
+
subfolder: z.string().optional().describe("Optional subfolder within the folder."),
|
|
897
|
+
},
|
|
898
|
+
async ({ folder, name, content, subfolder }) => {
|
|
899
|
+
try {
|
|
900
|
+
await post("/v2/company/propose", { folder, name, content, subfolder });
|
|
901
|
+
return { content: [{ type: "text", text: `Filed "${name}" into the company vault (${folder}). It's now searchable by the team's agents via search_company_knowledge.` }] };
|
|
902
|
+
} catch (e) {
|
|
903
|
+
const msg = String(e?.message || e);
|
|
904
|
+
if (msg.includes("admins_only") || msg.includes("(403)")) {
|
|
905
|
+
return { content: [{ type: "text", text: `Not filed — writing to the company vault is admin-only. You can READ it with search_company_knowledge, but only an owner/admin can add to it.` }] };
|
|
906
|
+
}
|
|
907
|
+
throw e;
|
|
908
|
+
}
|
|
909
|
+
},
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
// ===========================================================================
|
|
913
|
+
// TOOL: get_workspace_status — GET /v2/workspace/status
|
|
914
|
+
// The "one main call." Edda is operated by the agent, so the agent needs to
|
|
915
|
+
// know the state of the workspace: is it onboarded, is the GTM foundation built,
|
|
916
|
+
// which integrations are connected, is CRM sync configured, are events live —
|
|
917
|
+
// and what to set up next. Call this at the start of a session.
|
|
918
|
+
// ===========================================================================
|
|
919
|
+
server.tool(
|
|
920
|
+
"get_workspace_status",
|
|
921
|
+
"See the whole setup state of this workspace in one call, plus a ranked NEXT STEPS list (each step " +
|
|
922
|
+
"carries its own why/how). Edda is operated by you, the agent — call this at the START of a session " +
|
|
923
|
+
"and walk the user top-down through the steps it returns; the server sequences them by current " +
|
|
924
|
+
"state, so trust that order. Two constraints when acting on them: (1) Gmail (Google OAuth) and " +
|
|
925
|
+
"LinkedIn (no public API — Edda uses Unipile) CANNOT be connected by you — point the user to the " +
|
|
926
|
+
"Integrations page; key-based tools (Prospeo, Apollo, Instantly, HubSpot token) you CAN connect via " +
|
|
927
|
+
"connect_integration, and CSV import is a user action in the app. (2) Respect the plan — never push " +
|
|
928
|
+
"a feature it doesn't include (e.g. CRM sync on free). Recommend the next 1-2 steps, don't dump the " +
|
|
929
|
+
"whole list.",
|
|
930
|
+
{},
|
|
931
|
+
async () => {
|
|
932
|
+
const s = await get("/v2/workspace/status");
|
|
933
|
+
const setup = s.setup ?? {};
|
|
934
|
+
const lines = [];
|
|
935
|
+
|
|
936
|
+
const ws = s.workspace ?? {};
|
|
937
|
+
lines.push(`WORKSPACE: ${ws.name || "(unnamed)"}${ws.website ? ` · ${ws.website}` : ""}${ws.business_type ? ` · ${ws.business_type}` : ""}`);
|
|
938
|
+
const pl = s.plan ?? {};
|
|
939
|
+
lines.push(`PLAN: ${pl.name || pl.id || "free"}${pl.crm_sync === false ? " (CRM sync not included — do not offer it)" : ""}`);
|
|
940
|
+
if (s.self_hosted) {
|
|
941
|
+
const e = s.env_integrations ?? {};
|
|
942
|
+
const mk = (b) => (b ? "✓ set" : "✗ NOT set");
|
|
943
|
+
lines.push("SELF-HOSTED — these channels are wired via edda.env (you can't set env vars; tell the operator to set + restart):");
|
|
944
|
+
lines.push(` LinkedIn/Unipile: ${mk(e.linkedin_unipile)} Email/Resend: ${mk(e.email_resend)} Gmail OAuth: ${mk(e.gmail_oauth)}`);
|
|
945
|
+
}
|
|
946
|
+
lines.push("");
|
|
947
|
+
|
|
948
|
+
const mark = (b) => (b ? "✓" : "✗");
|
|
949
|
+
lines.push("SETUP:");
|
|
950
|
+
lines.push(` ${mark(setup.onboarding?.done)} Profile${setup.onboarding?.done ? "" : ` — missing ${(setup.onboarding?.missing ?? []).join(", ") || "details"}`}`);
|
|
951
|
+
const ints = setup.integrations?.connected ?? [];
|
|
952
|
+
lines.push(` ${mark((setup.integrations?.count ?? 0) > 0)} Integrations (${setup.integrations?.count ?? 0})${ints.length ? `: ${ints.map((i) => i.name).join(", ")}` : ""}`);
|
|
953
|
+
const crm = setup.crm_sync ?? {};
|
|
954
|
+
if (crm.available === false) {
|
|
955
|
+
lines.push(` – CRM sync (not on the ${pl.name || pl.id || "current"} plan)`);
|
|
956
|
+
} else {
|
|
957
|
+
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` : ""}`);
|
|
958
|
+
}
|
|
959
|
+
lines.push(` ${mark(setup.enrichment?.connected)} Enrichment${setup.enrichment?.provider ? `: ${setup.enrichment.provider}` : ""}`);
|
|
960
|
+
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)`);
|
|
961
|
+
const rec = setup.recommended ?? {};
|
|
962
|
+
lines.push("");
|
|
963
|
+
lines.push("RECOMMENDED CHANNELS (connect these first):");
|
|
964
|
+
lines.push(` ${mark(rec.email)} Email / Gmail ${mark(rec.linkedin)} LinkedIn ${mark(rec.meeting_notetaker)} Meeting note-taker`);
|
|
965
|
+
lines.push(` Records imported: ${setup.records?.count ?? 0}`);
|
|
966
|
+
|
|
967
|
+
if (s.next_steps?.length) {
|
|
968
|
+
lines.push("");
|
|
969
|
+
lines.push("NEXT STEPS:");
|
|
970
|
+
for (const step of s.next_steps) {
|
|
971
|
+
lines.push(` • ${step.title}`);
|
|
972
|
+
if (step.why) lines.push(` why: ${step.why}`);
|
|
973
|
+
if (step.how) lines.push(` how: ${step.how}`);
|
|
974
|
+
}
|
|
975
|
+
} else {
|
|
976
|
+
lines.push("");
|
|
977
|
+
lines.push("Everything's set up. Nothing pending.");
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
981
|
+
}
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
// ===========================================================================
|
|
985
|
+
// TOOL: set_workspace_profile — POST /v2/workspace/onboarding
|
|
986
|
+
// Agent-driven onboarding. Instead of a human clicking through a wizard in the
|
|
987
|
+
// app, you collect the basics from the user in conversation and write them
|
|
988
|
+
// here. This is the first thing get_workspace_status asks for when a workspace
|
|
989
|
+
// is new.
|
|
990
|
+
// ===========================================================================
|
|
991
|
+
server.tool(
|
|
992
|
+
"set_workspace_profile",
|
|
993
|
+
"Set or update the workspace's basic identity — its company name and website. Send only the fields " +
|
|
994
|
+
"you're setting or changing.",
|
|
995
|
+
{
|
|
996
|
+
name: z.string().optional().describe("The company / workspace name."),
|
|
997
|
+
website: z.string().optional().describe("The company website."),
|
|
998
|
+
},
|
|
999
|
+
async ({ name, website }) => {
|
|
1000
|
+
const r = await post("/v2/workspace/onboarding", { name, website });
|
|
1001
|
+
const w = r.workspace ?? {};
|
|
1002
|
+
const set = [
|
|
1003
|
+
w.name && `name=${w.name}`,
|
|
1004
|
+
w.website && `site=${w.website}`,
|
|
1005
|
+
].filter(Boolean);
|
|
1006
|
+
return { content: [{ type: "text", text:
|
|
1007
|
+
`Workspace profile saved.${set.length ? ` ${set.join(" · ")}.` : ""}` }] };
|
|
1008
|
+
}
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
// ===========================================================================
|
|
1012
|
+
// TOOL: connect_integration — POST /v2/workspace/integrations
|
|
1013
|
+
// The agent connects a KEY-BASED integration for the user (no clicking through
|
|
1014
|
+
// the Integrations page). OAuth providers still need a browser, so this is
|
|
1015
|
+
// limited to providers that authenticate with an API key/token.
|
|
1016
|
+
// ===========================================================================
|
|
1017
|
+
server.tool(
|
|
1018
|
+
"connect_integration",
|
|
1019
|
+
"Connect a key-based integration for the user — an enrichment, CRM, or sequencer provider that " +
|
|
1020
|
+
"authenticates with an API key or token (e.g. Apollo, Prospeo, Instantly, HubSpot private-app " +
|
|
1021
|
+
"token, Pipedrive, Attio, Smartlead, HeyReach). Ask the user for the provider's API key, then " +
|
|
1022
|
+
"call this; it verifies the credentials before saving. Providers that use a browser sign-in " +
|
|
1023
|
+
"(OAuth, e.g. Gmail) can't be connected this way — for those, point the user to the Integrations " +
|
|
1024
|
+
"page. After connecting an enrichment provider, the account record starts filling in.",
|
|
1025
|
+
{
|
|
1026
|
+
provider: z.string().describe("Provider name, lowercase — e.g. 'apollo', 'prospeo', 'instantly', 'hubspot', 'pipedrive', 'attio'."),
|
|
1027
|
+
credentials: z.record(z.string()).describe("The provider's credentials as key/value, e.g. { api_key: '...' } or { access_token: '...' }."),
|
|
1028
|
+
name: z.string().optional().describe("Optional label for the connection."),
|
|
1029
|
+
},
|
|
1030
|
+
async ({ provider, credentials, name }) => {
|
|
1031
|
+
try {
|
|
1032
|
+
const r = await post("/v2/workspace/integrations", { provider, credentials, name });
|
|
1033
|
+
return { content: [{ type: "text", text: `Connected ${r.connection?.provider ?? provider}.${r.message ? ` ${r.message}` : ""}` }] };
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
const msg = String(e?.message ?? e);
|
|
1036
|
+
if (msg.includes("oauth_provider")) {
|
|
1037
|
+
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.` }] };
|
|
1038
|
+
}
|
|
1039
|
+
if (msg.includes("invalid_credentials")) {
|
|
1040
|
+
return { content: [{ type: "text", text: `Those credentials didn't verify for ${provider}. Ask the user to double-check the key and try again.` }] };
|
|
1041
|
+
}
|
|
1042
|
+
if (msg.includes("unknown_provider")) {
|
|
1043
|
+
return { content: [{ type: "text", text: `No provider named "${provider}". Ask the user which tool they mean.` }] };
|
|
1044
|
+
}
|
|
1045
|
+
throw e;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
// ===========================================================================
|
|
1051
|
+
// TOOLS: the CORRECTION layer — unsay something recorded by mistake. `record` and
|
|
1052
|
+
// `save_note` are how you write; these are how you take it back. Both heal the
|
|
1053
|
+
// derived layer: retracting an observation re-derives the claim from what remains,
|
|
1054
|
+
// deleting a note drops it from search/context. DELETE /v2/observations|notes/:id.
|
|
1055
|
+
// ===========================================================================
|
|
1056
|
+
server.tool(
|
|
1057
|
+
"retract_observation",
|
|
1058
|
+
"RETRACT an observation you recorded by mistake, and heal the record. Pass the observation's " +
|
|
1059
|
+
"`id` (returned by `record`). Edda deletes it and re-derives the affected fact from the " +
|
|
1060
|
+
"observations that remain — so a wrong value you observed is un-observed and the claim reverts as " +
|
|
1061
|
+
"if it had never happened; if it was the only observation for that fact, the fact is invalidated. " +
|
|
1062
|
+
"Use this when you recorded the wrong thing (wrong value, wrong person, a test), NOT to represent a " +
|
|
1063
|
+
"real change over time — a genuine change is a NEW `record`, which supersedes by recency.",
|
|
1064
|
+
{
|
|
1065
|
+
id: z.string().describe("The observation id to retract (from a prior `record` result)."),
|
|
1066
|
+
},
|
|
1067
|
+
async ({ id }) => {
|
|
1068
|
+
try {
|
|
1069
|
+
const r = await del(`/v2/observations/${encodeURIComponent(id)}`);
|
|
1070
|
+
return { content: [{ type: "text", text:
|
|
1071
|
+
`Observation retracted. The claim for ${r.property} was ${r.claim === "invalidated" ? "invalidated (no observations left)" : "re-derived from the remaining observations"}.` }] };
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
const msg = /observation_not_found/.test(e.message)
|
|
1074
|
+
? "No observation with that id in this workspace — check the id from the record result."
|
|
1075
|
+
: `Couldn't retract the observation: ${e.message}`;
|
|
1076
|
+
return { content: [{ type: "text", text: msg }] };
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
server.tool(
|
|
1082
|
+
"delete_note",
|
|
1083
|
+
"DELETE a note saved by mistake. Pass the note's `id` (returned by `save_note`). It's removed from " +
|
|
1084
|
+
"search and context immediately; the timeline stays reconstructable. Only touches notes — it will " +
|
|
1085
|
+
"refuse an id that isn't a note. Use this for a note saved in error; to CHANGE a note, save a new " +
|
|
1086
|
+
"one instead.",
|
|
1087
|
+
{
|
|
1088
|
+
id: z.string().describe("The note id to delete (from a prior `save_note` result)."),
|
|
1089
|
+
},
|
|
1090
|
+
async ({ id }) => {
|
|
1091
|
+
try {
|
|
1092
|
+
const r = await del(`/v2/notes/${encodeURIComponent(id)}`);
|
|
1093
|
+
return { content: [{ type: "text", text:
|
|
1094
|
+
r.status === "already_deleted" ? "That note was already deleted." : "Note deleted — it's out of search and context now." }] };
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
const msg = /note_not_found/.test(e.message)
|
|
1097
|
+
? "No note with that id in this workspace — check the id from the save_note result."
|
|
1098
|
+
: /not_a_note/.test(e.message)
|
|
1099
|
+
? "That id isn't a note, so it can't be deleted here. Only save_note notes can be deleted this way."
|
|
1100
|
+
: `Couldn't delete the note: ${e.message}`;
|
|
1101
|
+
return { content: [{ type: "text", text: msg }] };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
);
|
|
1105
|
+
|
|
1106
|
+
// ===========================================================================
|
|
1107
|
+
// TOOLS: list + introspect — enumerate what's on the workspace, and who you are.
|
|
1108
|
+
// GET /v2/notes · /v2/workspace/integrations · /v2/workspace/members.
|
|
1109
|
+
// ===========================================================================
|
|
1110
|
+
server.tool(
|
|
1111
|
+
"list_notes",
|
|
1112
|
+
"List saved notes newest-first (chronological), optionally scoped to one person/company with " +
|
|
1113
|
+
"`focus`. This is the LIST companion to search_notes (which is semantic): use it for \"show me the " +
|
|
1114
|
+
"notes on X\", or to enumerate notes and get their ids — e.g. to pick one to delete_note. Returns " +
|
|
1115
|
+
"id, type, title, date and a snippet for each.",
|
|
1116
|
+
{
|
|
1117
|
+
focus: z.string().optional().describe("Restrict to one person/company (email, LinkedIn URL, entity UUID, or domain). Omit for the whole workspace."),
|
|
1118
|
+
limit: z.number().int().min(1).max(100).optional().describe("Max notes to return (default 20)."),
|
|
1119
|
+
},
|
|
1120
|
+
async ({ focus, limit }) => {
|
|
1121
|
+
const r = await get("/v2/notes", { ...(focus ? { focus } : {}), ...(limit ? { limit } : {}) });
|
|
1122
|
+
if (r.status === "ambiguous") {
|
|
1123
|
+
const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
|
|
1124
|
+
return { content: [{ type: "text", text: `"${focus}" matches several — re-call list_notes with one of these entity ids as focus:\n${opts}` }] };
|
|
1125
|
+
}
|
|
1126
|
+
if (!r.notes?.length) return { content: [{ type: "text", text: focus ? "No notes on that account." : "No notes saved yet." }] };
|
|
1127
|
+
const lines = r.notes.map(n => ` • ${n.title || n.type}${n.date ? ` (${n.date})` : ""} [${n.id}]\n ${n.snippet}`).join("\n");
|
|
1128
|
+
return { content: [{ type: "text", text: `${r.notes.length} note(s)${r.has_more ? " (more available — raise limit)" : ""}:\n${lines}` }] };
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
server.tool(
|
|
1133
|
+
"list_integrations",
|
|
1134
|
+
"List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +
|
|
1135
|
+
"— what's wired in and whether it's verified. Use before telling the user to connect something, or " +
|
|
1136
|
+
"to answer \"what's connected here?\".",
|
|
1137
|
+
{},
|
|
1138
|
+
async () => {
|
|
1139
|
+
const r = await get("/v2/workspace/integrations");
|
|
1140
|
+
if (!r.integrations?.length) return { content: [{ type: "text", text: "No integrations connected yet." }] };
|
|
1141
|
+
const lines = r.integrations.map(i => ` • ${i.display_name}${i.category ? ` (${i.category})` : ""}${i.verified ? "" : " — not verified"}`).join("\n");
|
|
1142
|
+
return { content: [{ type: "text", text: `Connected integrations:\n${lines}` }] };
|
|
1143
|
+
}
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
server.tool(
|
|
1147
|
+
"whoami",
|
|
1148
|
+
"Report who this API key acts AS and who else is on the workspace — the agent's own identity in " +
|
|
1149
|
+
"Edda. Returns your scope (a MEMBER key sees only that member's private content plus the shared " +
|
|
1150
|
+
"graph; an ADMIN key sees all raw content) and the team roster with names and roles. Use it to " +
|
|
1151
|
+
"understand whose view you have and to reference teammates. Emails show only for an admin key.",
|
|
1152
|
+
{},
|
|
1153
|
+
async () => {
|
|
1154
|
+
const r = await get("/v2/workspace/members");
|
|
1155
|
+
const scope = r.you?.scope === "admin"
|
|
1156
|
+
? "an ADMIN key — you see all raw content on this workspace"
|
|
1157
|
+
: "a MEMBER key — you see only your own private content plus the shared graph";
|
|
1158
|
+
const roster = (r.members || [])
|
|
1159
|
+
.map(m => ` • ${m.name || "(unnamed)"} — ${m.role}${m.you ? " (you)" : ""}${m.email ? ` · ${m.email}` : ""}`)
|
|
1160
|
+
.join("\n");
|
|
1161
|
+
return { content: [{ type: "text", text: `You are ${scope}.\n\nWorkspace members (${r.count ?? 0}):\n${roster || " (none)"}` }] };
|
|
1162
|
+
}
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
// ===========================================================================
|
|
1166
|
+
// TOOL: get_routing_preferences
|
|
1167
|
+
// The routing preferences that make THIS agent default to Edda for GTM. The
|
|
1168
|
+
// agent applies them itself in Claude Code (write to CLAUDE.md); for claude.ai
|
|
1169
|
+
// the user pastes them into Org preferences. Not applicable to Codex/others.
|
|
1170
|
+
// ===========================================================================
|
|
1171
|
+
server.tool(
|
|
1172
|
+
"get_routing_preferences",
|
|
1173
|
+
"Get the routing preferences that make this agent treat Edda as the DEFAULT for GTM — so it " +
|
|
1174
|
+
"reaches for get_context / get_account instead of raw CRM or HubSpot / Salesforce / Gong / " +
|
|
1175
|
+
"Granola when someone forgets to say \"Edda\". This is an OPTIONAL onboarding step, and " +
|
|
1176
|
+
"CLAUDE CODE-specific. If you are running in Claude Code, set it up for the user: ask whether to " +
|
|
1177
|
+
"apply it to this project (write the returned text to ./CLAUDE.md) or to all projects " +
|
|
1178
|
+
"(~/.claude/CLAUDE.md), then write it there. The user can also paste it into claude.ai → Settings " +
|
|
1179
|
+
"→ Organization preferences (Team/Enterprise) or Personal preferences (Pro). If you are NOT Claude " +
|
|
1180
|
+
"Code (Codex, Cursor, n8n, …), this does not apply — skip it.",
|
|
1181
|
+
{},
|
|
1182
|
+
async () => {
|
|
1183
|
+
return { content: [{ type: "text", text:
|
|
1184
|
+
`Routing preferences (write to the user's CLAUDE.md in Claude Code, or have them paste into ` +
|
|
1185
|
+
`claude.ai → Settings → Organization/Personal preferences):\n\n${ROUTING_PREFERENCES}` }] };
|
|
1186
|
+
}
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
return server;
|
|
1190
|
+
}
|