@agifyai/leadify-mcp 6.0.1 → 7.1.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/README.md +4 -1
- package/dist/tools/pipeline.js +34 -3
- package/dist/tools/signals.js +231 -36
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -195,7 +195,10 @@ Conséquences pratiques :
|
|
|
195
195
|
| `get_campaign` | Récupérer les détails d'une campagne et ses KPIs temps réel. |
|
|
196
196
|
| `update_campaign_status` | Changer le statut d'une campagne (DRAFT, ACTIVE, PAUSED, COMPLETED). |
|
|
197
197
|
| `export_campaign` | Exporter les statistiques complètes d'une campagne en CSV. |
|
|
198
|
-
| `
|
|
198
|
+
| `signal_upsert` | Créer ou mettre à jour un signal de business intelligence (INFO, CRITICAL, GOLDEN). Remet le state à `active`. |
|
|
199
|
+
| `signal_expire` | Expirer un signal (événement périmé). Flip de `state` uniquement. |
|
|
200
|
+
| `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
|
|
201
|
+
| `signal_delete` | Supprimer définitivement un signal (cas rare : donnée erronée, doublon). |
|
|
199
202
|
| `add_activity` | Journaliser une interaction prospect (LinkedIn, email, call) dans le feed du lead. |
|
|
200
203
|
| `get_data_room` | Récupérer la data room complète : infos société (v2), documents, personas. |
|
|
201
204
|
| `describe_company_info_schema` | Lire le schéma companyInfo v2 mirroré côté MCP (enums, max-lengths, sections, patch tool par section). À appeler avant la première update. |
|
package/dist/tools/pipeline.js
CHANGED
|
@@ -4,7 +4,7 @@ import { toolResult, toolError, handleToolError } from "../types.js";
|
|
|
4
4
|
export function registerPipelineTools(server) {
|
|
5
5
|
// ── trigger_outreach ─────────────────────────────────────────────────────
|
|
6
6
|
server.tool("trigger_outreach", "Generate the outreach message SEQUENCE for a lead via the Trigger.dev agent " +
|
|
7
|
-
"(Leadify
|
|
7
|
+
"(Leadify v3 writer). The agent is a PURE MESSAGE WRITER: it produces a multi-touch " +
|
|
8
8
|
"sequence (card_connexion, card_linkedin_one/two/three, card_email_one/two/three plus " +
|
|
9
9
|
"their subjects), filtered to the channels the lead can actually receive. It does NOT " +
|
|
10
10
|
"score, tier, analyse or research — there is no card_analysis/card_research/score/tier " +
|
|
@@ -27,7 +27,14 @@ export function registerPipelineTools(server) {
|
|
|
27
27
|
"this is NOT a failure — the run keeps going server-side, follow it via the " +
|
|
28
28
|
"traceUrl. In dry-run (dryrun=true) the call is safe to retry, since nothing is " +
|
|
29
29
|
"persisted; for production writes (dryrun=false) prefer following the trace over " +
|
|
30
|
-
"blindly retrying to avoid duplicate generation."
|
|
30
|
+
"blindly retrying to avoid duplicate generation. " +
|
|
31
|
+
"CARDS SUBSET (cards_to_generate): pass an explicit list of card names to restrict " +
|
|
32
|
+
"the generation to a specific subset. Use this for single-touch campaigns or " +
|
|
33
|
+
"when the full 10-card cycle is not needed. Omit to generate the full sequence " +
|
|
34
|
+
"(default). Subject fields (card_email_one_subject, etc.) should always accompany " +
|
|
35
|
+
"their body card. Valid card names: card_connexion, card_linkedin_one, " +
|
|
36
|
+
"card_linkedin_two, card_linkedin_three, card_email_one, card_email_one_subject, " +
|
|
37
|
+
"card_email_two, card_email_two_subject, card_email_three, card_email_three_subject.", {
|
|
31
38
|
lead_id: z.string().describe("ID of the lead to generate a message for."),
|
|
32
39
|
dryrun: z
|
|
33
40
|
.boolean()
|
|
@@ -42,7 +49,28 @@ export function registerPipelineTools(server) {
|
|
|
42
49
|
.default(false)
|
|
43
50
|
.describe("true = bypass the 'already has a message?' check so you can re-generate. " +
|
|
44
51
|
"false (default) = normal behaviour: skip leads that already have card_message."),
|
|
45
|
-
|
|
52
|
+
cards_to_generate: z
|
|
53
|
+
.array(z.enum([
|
|
54
|
+
"card_connexion",
|
|
55
|
+
"card_linkedin_one",
|
|
56
|
+
"card_linkedin_two",
|
|
57
|
+
"card_linkedin_three",
|
|
58
|
+
"card_email_one",
|
|
59
|
+
"card_email_one_subject",
|
|
60
|
+
"card_email_two",
|
|
61
|
+
"card_email_two_subject",
|
|
62
|
+
"card_email_three",
|
|
63
|
+
"card_email_three_subject",
|
|
64
|
+
]))
|
|
65
|
+
.min(1)
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("Optional subset of outreach cards to generate. When omitted, all cards are " +
|
|
68
|
+
"generated (default behaviour — full 10-card sequence). When provided, ONLY " +
|
|
69
|
+
"these cards are generated. Example for single-touch: " +
|
|
70
|
+
"['card_connexion', 'card_linkedin_one', 'card_email_one', 'card_email_one_subject']. " +
|
|
71
|
+
"Subject fields must always accompany their body card. Cards for channels the " +
|
|
72
|
+
"lead doesn't have (e.g. email when no email address) are silently dropped."),
|
|
73
|
+
}, async ({ lead_id, dryrun, force, cards_to_generate }) => {
|
|
46
74
|
try {
|
|
47
75
|
// Resolve leadGroupId server-side. The Trigger.dev task payload requires
|
|
48
76
|
// `leadGroupId`, but the lead_id alone is enough on the MCP side — we
|
|
@@ -68,6 +96,9 @@ export function registerPipelineTools(server) {
|
|
|
68
96
|
dryrun,
|
|
69
97
|
force,
|
|
70
98
|
};
|
|
99
|
+
if (cards_to_generate && cards_to_generate.length > 0) {
|
|
100
|
+
body.cardsToGenerate = cards_to_generate;
|
|
101
|
+
}
|
|
71
102
|
const data = (await getClient().post("/api/trigger-outreach", body));
|
|
72
103
|
// Decorate the response with a Trigger.dev run deep-link when the
|
|
73
104
|
// backend didn't ship one. Applies to BOTH success and failure paths
|
package/dist/tools/signals.js
CHANGED
|
@@ -1,47 +1,242 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { getClient } from "../client.js";
|
|
3
|
-
import { toolResult, handleToolError } from "../types.js";
|
|
3
|
+
import { toolResult, toolError, handleToolError } from "../types.js";
|
|
4
|
+
// Anti-loop guardrail, NOT a business limit: bounds how many signal mutations
|
|
5
|
+
// a single server run (= one agent session) can perform.
|
|
6
|
+
const MAX_SIGNAL_MUTATIONS_PER_RUN = 20;
|
|
7
|
+
let signalMutationCount = 0;
|
|
8
|
+
function capExceeded() {
|
|
9
|
+
if (signalMutationCount >= MAX_SIGNAL_MUTATIONS_PER_RUN) {
|
|
10
|
+
return toolError(`Signal mutation cap reached (${MAX_SIGNAL_MUTATIONS_PER_RUN} per run). ` +
|
|
11
|
+
"This is an anti-loop guardrail, not a business limit. If you genuinely " +
|
|
12
|
+
"need more mutations, ask the user to restart the MCP session.");
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
// Best-effort: a feed failure must never fail the signal mutation itself.
|
|
17
|
+
async function appendFeed(entry) {
|
|
18
|
+
try {
|
|
19
|
+
await getClient().post("/activity-feed", { mode: "agent", ...entry });
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function mutationResult(data, feedError) {
|
|
27
|
+
if (feedError === null)
|
|
28
|
+
return toolResult(data);
|
|
29
|
+
return toolResult({
|
|
30
|
+
result: data,
|
|
31
|
+
warning: `Signal mutation succeeded but the activity-feed entry failed: ${feedError}`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const reasonParam = z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("One factual sentence explaining WHY you are performing this mutation " +
|
|
37
|
+
"(e.g. 'Tender closed on 2026-05-30, budget consumed.'). Recorded in the " +
|
|
38
|
+
"lead's append-only activity feed.");
|
|
4
39
|
export function registerSignalTools(server) {
|
|
5
|
-
// ──
|
|
6
|
-
server.
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
// ── signal_upsert ──────────────────────────────────────────────────────
|
|
41
|
+
server.registerTool("signal_upsert", {
|
|
42
|
+
title: "Upsert a business signal",
|
|
43
|
+
description: "Create a new business intelligence signal on a lead, or update an existing " +
|
|
44
|
+
"one's content by `id`. Signals report key events picked up by agents — budget " +
|
|
45
|
+
"approvals, position changes, tender publications, etc. — and are shown in the " +
|
|
46
|
+
"lead's feed.\n\n" +
|
|
47
|
+
"WITHOUT `id` → creates a signal (`lead_id`, `level`, `title`, `content` required).\n" +
|
|
48
|
+
"WITH `id` → PARTIAL update of the existing signal: only the fields you pass are " +
|
|
49
|
+
"modified. Ids are server-generated — never invent one; an unknown `id` returns 404. " +
|
|
50
|
+
"`lead_id` is immutable after creation.\n\n" +
|
|
51
|
+
"Every upsert (re)sets the signal's state to `active`: updating an expired or " +
|
|
52
|
+
"disabled signal reactivates it — this is also the way to reactivate a signal. To " +
|
|
53
|
+
"expire or disable a signal use `signal_expire` / `signal_disable` instead.\n\n" +
|
|
54
|
+
"Levels: GOLDEN = major actionable buying-intent (use sparingly), CRITICAL = " +
|
|
55
|
+
"important info needing attention, INFO = monitoring-grade info.\n" +
|
|
56
|
+
"Always provide `ai_summary` when `content` exceeds ~3 sentences, otherwise the UI " +
|
|
57
|
+
"truncates `content`.",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
id: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("ID of an existing signal to update. OMIT to create a new signal. " +
|
|
63
|
+
"Unknown id → 404 (ids are server-generated, never invent one)."),
|
|
64
|
+
lead_id: z
|
|
65
|
+
.string()
|
|
66
|
+
.optional()
|
|
67
|
+
.describe("ID of the target lead. Required when creating; immutable afterwards."),
|
|
68
|
+
level: z
|
|
69
|
+
.enum(["INFO", "CRITICAL", "GOLDEN"])
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Signal priority level. Required when creating."),
|
|
72
|
+
title: z
|
|
73
|
+
.string()
|
|
74
|
+
.optional()
|
|
75
|
+
.describe("Short one-line title (e.g. 'Budget IRM approuvé'). Required when creating."),
|
|
76
|
+
content: z
|
|
77
|
+
.string()
|
|
78
|
+
.optional()
|
|
79
|
+
.describe("Long markdown version: full details, sources, context. Required when creating."),
|
|
80
|
+
ai_summary: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("3-4 sentence markdown summary, shown by default in the UI with a 'see more' " +
|
|
84
|
+
"toggle expanding `content`."),
|
|
85
|
+
date: z
|
|
86
|
+
.string()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Date the business event actually occurred (≠ insertion date). Partial or full " +
|
|
89
|
+
"ISO 8601: 'YYYY' / 'YYYY-MM' / 'YYYY-MM-DD' / datetime. Use the finest " +
|
|
90
|
+
"precision you can reliably determine."),
|
|
91
|
+
source: z
|
|
92
|
+
.string()
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Human-readable source name ('LinkedIn', 'BOAMP', 'Les Echos'). Main badge in UI."),
|
|
95
|
+
agent_source: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("Technical identifier of the producing agent, 3-4 words max, kebab-case or " +
|
|
99
|
+
"space-separated (e.g. 'leadify qualification', 'budget-monitor')."),
|
|
100
|
+
source_url: z.string().url().optional().describe("URL of the original source."),
|
|
101
|
+
reason: reasonParam,
|
|
102
|
+
},
|
|
103
|
+
annotations: { idempotentHint: true },
|
|
104
|
+
}, async ({ id, lead_id, level, title, content, ai_summary, date, source, agent_source, source_url, reason, }) => {
|
|
105
|
+
const capError = capExceeded();
|
|
106
|
+
if (capError)
|
|
107
|
+
return capError;
|
|
32
108
|
try {
|
|
33
|
-
const body = {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
109
|
+
const body = { state: "active" };
|
|
110
|
+
if (id !== undefined)
|
|
111
|
+
body.id = id;
|
|
112
|
+
if (lead_id !== undefined)
|
|
113
|
+
body.leadId = lead_id;
|
|
114
|
+
if (level !== undefined)
|
|
115
|
+
body.level = level;
|
|
116
|
+
if (title !== undefined)
|
|
117
|
+
body.title = title;
|
|
118
|
+
if (content !== undefined)
|
|
119
|
+
body.content = content;
|
|
120
|
+
if (ai_summary !== undefined)
|
|
121
|
+
body.aiSummary = ai_summary;
|
|
122
|
+
if (date !== undefined)
|
|
123
|
+
body.date = date;
|
|
124
|
+
if (source !== undefined)
|
|
125
|
+
body.source = source;
|
|
39
126
|
if (agent_source !== undefined)
|
|
40
127
|
body.agentSource = agent_source;
|
|
41
128
|
if (source_url !== undefined)
|
|
42
129
|
body.sourceUrl = source_url;
|
|
43
|
-
const data = await getClient().post("/
|
|
44
|
-
|
|
130
|
+
const data = (await getClient().post("/signals", body));
|
|
131
|
+
signalMutationCount++;
|
|
132
|
+
const feedLeadId = data.signal?.leadId ?? lead_id;
|
|
133
|
+
const feedError = feedLeadId
|
|
134
|
+
? await appendFeed({
|
|
135
|
+
leadId: feedLeadId,
|
|
136
|
+
action: data.created ? "signal_added" : "signal_updated",
|
|
137
|
+
target: data.signal?.id ?? id,
|
|
138
|
+
after: data.signal?.title ?? title,
|
|
139
|
+
reason,
|
|
140
|
+
})
|
|
141
|
+
: "leadId missing from upsert response";
|
|
142
|
+
return mutationResult(data, feedError);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
return handleToolError(error);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// ── signal_expire / signal_disable ─────────────────────────────────────
|
|
149
|
+
const stateFlips = [
|
|
150
|
+
{
|
|
151
|
+
name: "signal_expire",
|
|
152
|
+
title: "Expire a business signal",
|
|
153
|
+
state: "expired",
|
|
154
|
+
action: "signal_expired",
|
|
155
|
+
description: "Mark an existing signal as EXPIRED: the underlying business event is no longer " +
|
|
156
|
+
"current (budget consumed, tender closed, contact left the position...). The signal " +
|
|
157
|
+
"is kept for history but stops counting as active intelligence. This only flips the " +
|
|
158
|
+
"signal's lifecycle state — content is untouched. Re-upserting the signal with " +
|
|
159
|
+
"`signal_upsert` reactivates it. Do NOT use this for wrong/duplicate data — that is " +
|
|
160
|
+
"`signal_delete`.",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "signal_disable",
|
|
164
|
+
title: "Disable a business signal",
|
|
165
|
+
state: "disabled",
|
|
166
|
+
action: "signal_disabled",
|
|
167
|
+
description: "Mark an existing signal as DISABLED: the signal is set aside as a false positive " +
|
|
168
|
+
"or manually discarded (wrong interpretation, irrelevant to this lead...). The " +
|
|
169
|
+
"signal is kept for traceability but ignored by agents and the UI. This only flips " +
|
|
170
|
+
"the signal's lifecycle state — content is untouched. Re-upserting the signal with " +
|
|
171
|
+
"`signal_upsert` reactivates it. For factually wrong or duplicate data, prefer " +
|
|
172
|
+
"`signal_delete`.",
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
for (const flip of stateFlips) {
|
|
176
|
+
server.registerTool(flip.name, {
|
|
177
|
+
title: flip.title,
|
|
178
|
+
description: flip.description,
|
|
179
|
+
inputSchema: {
|
|
180
|
+
id: z.string().describe("ID of the signal. Unknown id → 404."),
|
|
181
|
+
reason: reasonParam,
|
|
182
|
+
},
|
|
183
|
+
annotations: { idempotentHint: true },
|
|
184
|
+
}, async ({ id, reason }) => {
|
|
185
|
+
const capError = capExceeded();
|
|
186
|
+
if (capError)
|
|
187
|
+
return capError;
|
|
188
|
+
try {
|
|
189
|
+
const data = (await getClient().post("/signals", {
|
|
190
|
+
id,
|
|
191
|
+
state: flip.state,
|
|
192
|
+
}));
|
|
193
|
+
signalMutationCount++;
|
|
194
|
+
const feedLeadId = data.signal?.leadId;
|
|
195
|
+
const feedError = feedLeadId
|
|
196
|
+
? await appendFeed({
|
|
197
|
+
leadId: feedLeadId,
|
|
198
|
+
action: flip.action,
|
|
199
|
+
target: id,
|
|
200
|
+
after: flip.state.toUpperCase(),
|
|
201
|
+
reason,
|
|
202
|
+
})
|
|
203
|
+
: "leadId missing from upsert response";
|
|
204
|
+
return mutationResult(data, feedError);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return handleToolError(error);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// ── signal_delete ──────────────────────────────────────────────────────
|
|
212
|
+
server.registerTool("signal_delete", {
|
|
213
|
+
title: "Delete a business signal (rare)",
|
|
214
|
+
description: "PERMANENTLY delete a signal. RARE case reserved for factually wrong data or " +
|
|
215
|
+
"duplicates. The normal lifecycle never deletes: an outdated signal becomes " +
|
|
216
|
+
"`expired` (`signal_expire`) and a false positive becomes `disabled` " +
|
|
217
|
+
"(`signal_disable`), both keeping history. Deletion is irreversible.",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
id: z.string().describe("ID of the signal to delete. Unknown id → 404."),
|
|
220
|
+
lead_id: z
|
|
221
|
+
.string()
|
|
222
|
+
.describe("ID of the lead the signal belongs to (used for the activity feed)."),
|
|
223
|
+
reason: reasonParam,
|
|
224
|
+
},
|
|
225
|
+
annotations: { destructiveHint: true, idempotentHint: false },
|
|
226
|
+
}, async ({ id, lead_id, reason }) => {
|
|
227
|
+
const capError = capExceeded();
|
|
228
|
+
if (capError)
|
|
229
|
+
return capError;
|
|
230
|
+
try {
|
|
231
|
+
const data = await getClient().delete(`/signals/${encodeURIComponent(id)}`);
|
|
232
|
+
signalMutationCount++;
|
|
233
|
+
const feedError = await appendFeed({
|
|
234
|
+
leadId: lead_id,
|
|
235
|
+
action: "signal_deleted",
|
|
236
|
+
target: id,
|
|
237
|
+
reason,
|
|
238
|
+
});
|
|
239
|
+
return mutationResult(data, feedError);
|
|
45
240
|
}
|
|
46
241
|
catch (error) {
|
|
47
242
|
return handleToolError(error);
|