@agifyai/leadify-mcp 1.4.1

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.
@@ -0,0 +1,1268 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { LeadifyApiError, toolResult, toolError, handleToolError } from "../types.js";
4
+ // ─── Shared sub-schemas ────────────────────────────────────────────────────
5
+ // hotCriteria / warmCriteria / coldCriteria / disqualifiedCriteria
6
+ // Accept either a free-form string OR a structured {criteria, examples, weight} object.
7
+ const criteriaTier = z.union([
8
+ z.string(),
9
+ z
10
+ .object({
11
+ criteria: z.string().optional(),
12
+ examples: z.string().optional(),
13
+ weight: z.number().optional(),
14
+ })
15
+ .passthrough(),
16
+ ]);
17
+ const lookalikeClient = z.object({
18
+ name: z.string().describe("Client/company name (required)."),
19
+ sector: z.string().optional(),
20
+ size: z.string().optional(),
21
+ segment: z.string().optional(),
22
+ comparison_criteria: z
23
+ .string()
24
+ .optional()
25
+ .describe("Why this client is a useful reference."),
26
+ });
27
+ const painPoint = z.object({
28
+ title: z.string().optional(),
29
+ description: z.string().optional(),
30
+ });
31
+ const cardAnalysisSection = z.object({
32
+ id: z.string().optional(),
33
+ labels: z
34
+ .record(z.string())
35
+ .optional()
36
+ .describe("Multilingual labels, keyed by language code (e.g. { fr, en })."),
37
+ instructions: z.string().optional(),
38
+ });
39
+ const signalTierRule = z.object({
40
+ signal_id: z.string().optional(),
41
+ tier: z.enum(["hot", "warm", "cold"]).optional(),
42
+ });
43
+ const outreachTemplate = z
44
+ .object({
45
+ id: z.string().optional(),
46
+ channel: z.string().optional(),
47
+ signal: z
48
+ .string()
49
+ .optional()
50
+ .describe("Must exist in outreach_signal_routing.signals."),
51
+ field_name: z.string().optional(),
52
+ signal_context: z.string().optional(),
53
+ purpose: z.string().optional(),
54
+ example: z.string().optional(),
55
+ max_length: z.number().optional(),
56
+ label: z.string().optional(),
57
+ subject: z.string().optional(),
58
+ body: z.string().optional(),
59
+ })
60
+ .passthrough();
61
+ const outreachField = z
62
+ .object({
63
+ key: z.string().optional(),
64
+ label: z.string().optional(),
65
+ source: z.string().optional(),
66
+ })
67
+ .passthrough();
68
+ const extraPromptPhase = z.object({
69
+ scope: z.enum(["qualify", "outreach"]).optional(),
70
+ insertion_point: z
71
+ .enum(["prefix", "phase_1_5", "phase_2_extra", "suffix"])
72
+ .describe("Where in the prompt to inject this snippet."),
73
+ content: z.string().describe("Snippet content."),
74
+ });
75
+ const senderOverride = z.object({
76
+ name: z.string().describe("Sender display name (required)."),
77
+ title: z.string().optional(),
78
+ });
79
+ // ICP strategy — mental map consumed verbatim by the outreach agent preflight.
80
+ const icpStrategyShape = {
81
+ dealType: z.string().optional(),
82
+ dreamOutcome: z.string().optional(),
83
+ mainPains: z.array(z.string()).optional(),
84
+ keyObjections: z.array(z.string()).optional(),
85
+ decisionDrivers: z.array(z.string()).optional(),
86
+ };
87
+ // camelCase mapping for nested objects that the backend expects as-is.
88
+ function mapLookalikeClient(c) {
89
+ const out = { name: c.name };
90
+ if (c.sector !== undefined)
91
+ out.sector = c.sector;
92
+ if (c.size !== undefined)
93
+ out.size = c.size;
94
+ if (c.segment !== undefined)
95
+ out.segment = c.segment;
96
+ if (c.comparison_criteria !== undefined)
97
+ out.comparisonCriteria = c.comparison_criteria;
98
+ return out;
99
+ }
100
+ function mapCardSection(s) {
101
+ const out = {};
102
+ if (s.id !== undefined)
103
+ out.id = s.id;
104
+ if (s.labels !== undefined)
105
+ out.labels = s.labels;
106
+ if (s.instructions !== undefined)
107
+ out.instructions = s.instructions;
108
+ return out;
109
+ }
110
+ function mapOutreachTemplate(t) {
111
+ const out = {};
112
+ if (t.id !== undefined)
113
+ out.id = t.id;
114
+ if (t.channel !== undefined)
115
+ out.channel = t.channel;
116
+ if (t.signal !== undefined)
117
+ out.signal = t.signal;
118
+ if (t.field_name !== undefined)
119
+ out.fieldName = t.field_name;
120
+ if (t.signal_context !== undefined)
121
+ out.signalContext = t.signal_context;
122
+ if (t.purpose !== undefined)
123
+ out.purpose = t.purpose;
124
+ if (t.example !== undefined)
125
+ out.example = t.example;
126
+ if (t.max_length !== undefined)
127
+ out.maxLength = t.max_length;
128
+ if (t.label !== undefined)
129
+ out.label = t.label;
130
+ if (t.subject !== undefined)
131
+ out.subject = t.subject;
132
+ if (t.body !== undefined)
133
+ out.body = t.body;
134
+ return out;
135
+ }
136
+ function mapSignalTierRule(r) {
137
+ const out = {};
138
+ if (r.signal_id !== undefined)
139
+ out.signalId = r.signal_id;
140
+ if (r.tier !== undefined)
141
+ out.tier = r.tier;
142
+ return out;
143
+ }
144
+ function mapExtraPromptPhase(p) {
145
+ const out = {
146
+ insertionPoint: p.insertion_point,
147
+ content: p.content,
148
+ };
149
+ if (p.scope !== undefined)
150
+ out.scope = p.scope;
151
+ return out;
152
+ }
153
+ /**
154
+ * Tag a thrown error with which RMW step failed (read vs write) and surface
155
+ * the inner status / response body. Patch tools have a 2-step flow (GET-then-
156
+ * POST) and a bare "Persona not found" gives no clue which call broke.
157
+ */
158
+ class PersonaPatchError extends Error {
159
+ step;
160
+ path;
161
+ statusCode;
162
+ responseBody;
163
+ hint;
164
+ constructor(step, path, statusCode, responseBody, hint) {
165
+ const inner = typeof responseBody === "object" &&
166
+ responseBody !== null &&
167
+ "error" in responseBody
168
+ ? String(responseBody.error)
169
+ : statusCode != null
170
+ ? `HTTP ${statusCode}`
171
+ : "request failed";
172
+ super(`[step: ${step}] ${path} → ${inner}${hint ? ` — ${hint}` : ""}`);
173
+ this.step = step;
174
+ this.path = path;
175
+ this.statusCode = statusCode;
176
+ this.responseBody = responseBody;
177
+ this.hint = hint;
178
+ this.name = "PersonaPatchError";
179
+ }
180
+ }
181
+ /**
182
+ * Read a persona for the RMW patch flow.
183
+ *
184
+ * Backend asymmetry (verified empirically + documented in
185
+ * `agentic/trigger_agify` LeadifyCrmClient comments):
186
+ * - GET /api/persona/{id} → strict `persona.organizationId === user.organizationId`
187
+ * - POST /api/persona (upsert) → permissive `canAccessOrg(user, ...)`
188
+ * - GET /api/persona (list) → permissive via `buildOrgFilter`
189
+ *
190
+ * For super-admin keys or cross-org access, GET-by-id 404s while POST-upsert
191
+ * succeeds. Without a fallback, every patch tool 404s in those cases even
192
+ * when the user can clearly write to the persona via upsert.
193
+ *
194
+ * Strategy: fast-path the direct GET; on 404, fall back to the permissive
195
+ * list endpoint and find by id. Other errors propagate immediately.
196
+ */
197
+ async function fetchPersona(id) {
198
+ const directPath = `/api/persona/${encodeURIComponent(id)}`;
199
+ try {
200
+ const data = await getClient().get(directPath);
201
+ if (!data || typeof data !== "object") {
202
+ throw new PersonaPatchError("read", directPath, null, data, "Unexpected response shape (expected an object).");
203
+ }
204
+ // GET-by-id returns the persona body directly; defensive about a future
205
+ // projected/wrapped shape.
206
+ const persona = data.persona;
207
+ return persona ?? data;
208
+ }
209
+ catch (err) {
210
+ if (!(err instanceof LeadifyApiError) || err.statusCode !== 404) {
211
+ throw err;
212
+ }
213
+ // 404 on direct GET — fall through to list-based lookup.
214
+ }
215
+ // Permissive fallback: list returns everything the API key can see
216
+ // (own org + globals for regular users; cross-org for super admins).
217
+ const listPath = "/api/persona";
218
+ let listed;
219
+ try {
220
+ listed = await getClient().get(listPath);
221
+ }
222
+ catch (err) {
223
+ if (err instanceof LeadifyApiError) {
224
+ throw new PersonaPatchError("lookup", listPath, err.statusCode, err.responseBody, "Direct GET 404'd; fallback list lookup also failed.");
225
+ }
226
+ throw err;
227
+ }
228
+ const personas = listed?.personas;
229
+ if (!Array.isArray(personas)) {
230
+ throw new PersonaPatchError("lookup", listPath, null, listed, "List response missing 'personas' array.");
231
+ }
232
+ const found = personas.find((p) => p.id === id);
233
+ if (!found) {
234
+ throw new PersonaPatchError("lookup", directPath, 404, { error: "Persona not found" }, `Persona id "${id}" is not in your accessible personas. Verify the id with list_personas. ` +
235
+ `Note: GET /api/persona/{id} uses strict org-equality and 404s for cross-org access — ` +
236
+ `super-admin keys may need to operate via lead-group context. If list_personas returns this ` +
237
+ `id, the backend GET handler is the bug; tracker: align it with upsert's canAccessOrg semantics.`);
238
+ }
239
+ return found;
240
+ }
241
+ async function postPersonaUpdate(id, name, fieldsToWrite) {
242
+ const path = "/api/persona";
243
+ try {
244
+ return await getClient().post(path, {
245
+ id,
246
+ name,
247
+ ...fieldsToWrite,
248
+ });
249
+ }
250
+ catch (err) {
251
+ if (err instanceof LeadifyApiError) {
252
+ throw new PersonaPatchError("write", path, err.statusCode, err.responseBody, `Fields written: ${Object.keys(fieldsToWrite).join(", ") || "(none beyond id+name)"}`);
253
+ }
254
+ throw err;
255
+ }
256
+ }
257
+ function asObject(v) {
258
+ return v && typeof v === "object" && !Array.isArray(v)
259
+ ? v
260
+ : {};
261
+ }
262
+ function asArray(v) {
263
+ return Array.isArray(v) ? v : [];
264
+ }
265
+ /**
266
+ * Error handler for persona tools. Serializes PersonaPatchError into
267
+ * structured details (step, path, statusCode, responseBody, hint) so the
268
+ * caller can see exactly which sub-call failed and why — useful for the
269
+ * 12 patch tools' read-then-write flow. For other errors (LeadifyApiError
270
+ * from the wholesale tools, plain Error), falls through to handleToolError.
271
+ */
272
+ function handlePersonaToolError(error) {
273
+ if (error instanceof PersonaPatchError) {
274
+ return toolError(error.message, {
275
+ step: error.step,
276
+ path: error.path,
277
+ statusCode: error.statusCode,
278
+ response: error.responseBody,
279
+ hint: error.hint,
280
+ });
281
+ }
282
+ return handleToolError(error);
283
+ }
284
+ // ─── Tool registrations ────────────────────────────────────────────────────
285
+ export function registerPersonaTools(server) {
286
+ // ════════════════════════════════════════════════════════════════════════
287
+ // ESCAPE HATCH — wholesale upsert
288
+ // ════════════════════════════════════════════════════════════════════════
289
+ // ── upsert_persona ─────────────────────────────────────────────────────
290
+ server.tool("upsert_persona", "WHOLESALE create-or-update for a persona. Use to CREATE (no id) or to fully " +
291
+ "rewrite a persona. For partial edits, ALWAYS prefer the granular " +
292
+ "update_persona_* tools — they do safe read-modify-write merges and avoid " +
293
+ "wiping JSON columns you didn't intend to touch (each JSON column passed in " +
294
+ "this tool is replaced wholesale, so omitted sub-keys are LOST).", {
295
+ // core
296
+ id: z
297
+ .string()
298
+ .optional()
299
+ .describe("Persona ID to update an existing persona. Omit to create a new one."),
300
+ name: z.string().describe("Persona name (required)."),
301
+ organization_id: z
302
+ .string()
303
+ .nullable()
304
+ .optional()
305
+ .describe("Target organization ID (Clerk org ID). Absent = caller's org. Pass null to make the persona global (admin only)."),
306
+ description: z.string().optional().describe("Detailed persona description."),
307
+ // targeting
308
+ target_profile: z
309
+ .record(z.unknown())
310
+ .optional()
311
+ .describe("Target criteria. Common keys: job_titles, specialties, seniority_min, decision_maker, " +
312
+ "buying_power, target_company_types, min_company_size, interest_topics."),
313
+ disqualification_criteria: z
314
+ .record(z.unknown())
315
+ .optional()
316
+ .describe("Exclusion rules. Common keys: reasons (array of strings)."),
317
+ messaging_guidelines: z
318
+ .record(z.unknown())
319
+ .optional()
320
+ .describe("Message approach. Common keys: tone, language, can_mention, must_not_mention, value_propositions."),
321
+ active_tools: z
322
+ .array(z.string())
323
+ .optional()
324
+ .describe("Active monitoring/sourcing tools for this persona."),
325
+ // scoring tiers
326
+ hot_criteria: criteriaTier
327
+ .optional()
328
+ .describe("Criteria that make a lead 'hot'. Either a free-form string or {criteria, examples, weight}."),
329
+ warm_criteria: criteriaTier.optional().describe("Criteria that make a lead 'warm'."),
330
+ cold_criteria: criteriaTier.optional().describe("Criteria that make a lead 'cold'."),
331
+ disqualified_criteria: criteriaTier
332
+ .optional()
333
+ .describe("Criteria that disqualify a lead entirely."),
334
+ // references + pains
335
+ lookalike_clients: z
336
+ .array(lookalikeClient)
337
+ .optional()
338
+ .describe("2–3 official lookalike clients. Each must have at least a name."),
339
+ pain_points: z
340
+ .array(painPoint)
341
+ .optional()
342
+ .describe("Structured business pain points ({title, description})."),
343
+ // ICP strategy — mental map consumed verbatim by the outreach agent preflight
344
+ icp_strategy: z
345
+ .object(icpStrategyShape)
346
+ .passthrough()
347
+ .optional()
348
+ .describe("ICP strategy. Mental map for the outreach agent: {dealType, dreamOutcome, mainPains[], keyObjections[], decisionDrivers[]}."),
349
+ // behaviour flags
350
+ strict_match: z
351
+ .boolean()
352
+ .optional()
353
+ .describe("If true, reject non-perfectly-aligned leads."),
354
+ agent_domain: z
355
+ .string()
356
+ .nullable()
357
+ .optional()
358
+ .describe("Agent identity string (e.g. 'for medtech companies')."),
359
+ workflow_mode: z
360
+ .enum(["company_first", "person_first"])
361
+ .nullable()
362
+ .optional()
363
+ .describe("Qualification mode."),
364
+ output_language: z
365
+ .string()
366
+ .nullable()
367
+ .optional()
368
+ .describe("AI output language code (fr, en, de, …)."),
369
+ enable_signals: z
370
+ .boolean()
371
+ .optional()
372
+ .describe("Enable business signal detection for this persona."),
373
+ // prompt engineering
374
+ normalization_rules: z
375
+ .string()
376
+ .nullable()
377
+ .optional()
378
+ .describe("Free-form markdown injected into the ICP prompt."),
379
+ decision_maker_keywords: z
380
+ .record(z.array(z.string()))
381
+ .optional()
382
+ .describe("Terms implying decision power, keyed by language code. Example: { fr: ['Directeur', 'Chef de pôle'] }."),
383
+ card_analysis_sections: z
384
+ .object({
385
+ required: z.array(cardAnalysisSection).optional(),
386
+ optional: z.array(cardAnalysisSection).optional(),
387
+ })
388
+ .optional()
389
+ .describe("Analysis blocks separated into 'required' and 'optional'."),
390
+ signal_guidance: z
391
+ .string()
392
+ .nullable()
393
+ .optional()
394
+ .describe("Free-form markdown injected verbatim into signal prompt."),
395
+ signal_tier_rules: z
396
+ .object({
397
+ rules: z.array(signalTierRule).optional(),
398
+ })
399
+ .optional()
400
+ .describe("Rules mapping signal → tier (hot/warm/cold)."),
401
+ // outreach
402
+ outreach_signal_routing: z
403
+ .object({
404
+ signal_source_field: z
405
+ .string()
406
+ .optional()
407
+ .describe("Card field to read (e.g. card_signals)."),
408
+ default_signal: z
409
+ .string()
410
+ .optional()
411
+ .describe("Must exist in 'signals' array."),
412
+ signals: z
413
+ .array(z.string())
414
+ .optional()
415
+ .describe("Exhaustive list of recognized signals."),
416
+ })
417
+ .optional()
418
+ .describe("Extract signal source from card lead + default signal. All three sub-fields are optional."),
419
+ outreach_templates: z
420
+ .array(outreachTemplate)
421
+ .optional()
422
+ .describe("Outreach templates by channel. Each template: {id, channel, signal, field_name, signal_context, purpose, example, max_length, label, subject, body}."),
423
+ outreach_fields: z
424
+ .array(outreachField)
425
+ .optional()
426
+ .describe("Variables referenceable in templates. Each: {key, label, source}."),
427
+ tone_instructions: z
428
+ .string()
429
+ .nullable()
430
+ .optional()
431
+ .describe("Free-form markdown for voice / tone guidance."),
432
+ context: z
433
+ .string()
434
+ .nullable()
435
+ .optional()
436
+ .describe("Free-form markdown appended to the ICP prompt."),
437
+ extra_prompt_phases: z
438
+ .array(extraPromptPhase)
439
+ .optional()
440
+ .describe("Snippets inserted at named points. scope: 'qualify' | 'outreach'; insertion_point: 'prefix' | 'phase_1_5' | 'phase_2_extra' | 'suffix'."),
441
+ // sender
442
+ sender_override: senderOverride
443
+ .nullable()
444
+ .optional()
445
+ .describe("Override SDR identity at persona level ({name, title})."),
446
+ }, async (params) => {
447
+ try {
448
+ const body = { name: params.name };
449
+ if (params.id !== undefined)
450
+ body.id = params.id;
451
+ if (params.organization_id !== undefined)
452
+ body.organizationId = params.organization_id;
453
+ if (params.description !== undefined)
454
+ body.description = params.description;
455
+ if (params.target_profile !== undefined)
456
+ body.targetProfile = params.target_profile;
457
+ if (params.disqualification_criteria !== undefined)
458
+ body.disqualificationCriteria = params.disqualification_criteria;
459
+ if (params.messaging_guidelines !== undefined)
460
+ body.messagingGuidelines = params.messaging_guidelines;
461
+ if (params.active_tools !== undefined)
462
+ body.activeTools = params.active_tools;
463
+ if (params.hot_criteria !== undefined)
464
+ body.hotCriteria = params.hot_criteria;
465
+ if (params.warm_criteria !== undefined)
466
+ body.warmCriteria = params.warm_criteria;
467
+ if (params.cold_criteria !== undefined)
468
+ body.coldCriteria = params.cold_criteria;
469
+ if (params.disqualified_criteria !== undefined)
470
+ body.disqualifiedCriteria = params.disqualified_criteria;
471
+ if (params.lookalike_clients !== undefined)
472
+ body.lookalikeClients = params.lookalike_clients.map(mapLookalikeClient);
473
+ if (params.pain_points !== undefined)
474
+ body.painPoints = params.pain_points;
475
+ if (params.icp_strategy !== undefined)
476
+ body.icpStrategy = params.icp_strategy;
477
+ if (params.strict_match !== undefined)
478
+ body.strictMatch = params.strict_match;
479
+ if (params.agent_domain !== undefined)
480
+ body.agentDomain = params.agent_domain;
481
+ if (params.workflow_mode !== undefined)
482
+ body.workflowMode = params.workflow_mode;
483
+ if (params.output_language !== undefined)
484
+ body.outputLanguage = params.output_language;
485
+ if (params.enable_signals !== undefined)
486
+ body.enableSignals = params.enable_signals;
487
+ if (params.normalization_rules !== undefined)
488
+ body.normalizationRules = params.normalization_rules;
489
+ if (params.decision_maker_keywords !== undefined)
490
+ body.decisionMakerKeywords = params.decision_maker_keywords;
491
+ if (params.card_analysis_sections !== undefined) {
492
+ const cas = {};
493
+ if (params.card_analysis_sections.required !== undefined)
494
+ cas.required = params.card_analysis_sections.required.map(mapCardSection);
495
+ if (params.card_analysis_sections.optional !== undefined)
496
+ cas.optional = params.card_analysis_sections.optional.map(mapCardSection);
497
+ body.cardAnalysisSections = cas;
498
+ }
499
+ if (params.signal_guidance !== undefined)
500
+ body.signalGuidance = params.signal_guidance;
501
+ if (params.signal_tier_rules !== undefined) {
502
+ body.signalTierRules = {
503
+ rules: (params.signal_tier_rules.rules ?? []).map(mapSignalTierRule),
504
+ };
505
+ }
506
+ if (params.outreach_signal_routing !== undefined) {
507
+ const osr = {};
508
+ if (params.outreach_signal_routing.signal_source_field !== undefined)
509
+ osr.signalSourceField =
510
+ params.outreach_signal_routing.signal_source_field;
511
+ if (params.outreach_signal_routing.default_signal !== undefined)
512
+ osr.defaultSignal = params.outreach_signal_routing.default_signal;
513
+ if (params.outreach_signal_routing.signals !== undefined)
514
+ osr.signals = params.outreach_signal_routing.signals;
515
+ body.outreachSignalRouting = osr;
516
+ }
517
+ if (params.outreach_templates !== undefined)
518
+ body.outreachTemplates = params.outreach_templates.map(mapOutreachTemplate);
519
+ if (params.outreach_fields !== undefined)
520
+ body.outreachFields = params.outreach_fields;
521
+ if (params.tone_instructions !== undefined)
522
+ body.toneInstructions = params.tone_instructions;
523
+ if (params.context !== undefined)
524
+ body.context = params.context;
525
+ if (params.extra_prompt_phases !== undefined)
526
+ body.extraPromptPhases =
527
+ params.extra_prompt_phases.map(mapExtraPromptPhase);
528
+ if (params.sender_override !== undefined)
529
+ body.senderOverride = params.sender_override;
530
+ const data = await getClient().post("/api/persona", body);
531
+ return toolResult(data);
532
+ }
533
+ catch (error) {
534
+ return handlePersonaToolError(error);
535
+ }
536
+ });
537
+ // ── get_persona ────────────────────────────────────────────────────────
538
+ server.tool("get_persona", "Retrieve a single persona by ID, including all lead groups it's assigned to.", {
539
+ id: z.string().describe("Persona ID."),
540
+ }, async ({ id }) => {
541
+ try {
542
+ const data = await getClient().get(`/api/persona/${encodeURIComponent(id)}`);
543
+ return toolResult(data);
544
+ }
545
+ catch (error) {
546
+ return handlePersonaToolError(error);
547
+ }
548
+ });
549
+ // ── list_personas ──────────────────────────────────────────────────────
550
+ server.tool("list_personas", "List all personas accessible to the caller, optionally scoped to a specific organization " +
551
+ "and/or including global personas. Use this to discover persona IDs before calling " +
552
+ "get_persona, upsert_persona (for updates), or assigning a persona to a lead group. " +
553
+ "Returns a COMPACT view by default (id, name, organizationId, truncated description) " +
554
+ "to avoid blowing up context — full persona objects can be heavy (~10k chars each). " +
555
+ "Pass verbose=true to get the full objects, or call get_persona(id) for a single full persona.", {
556
+ organization_id: z
557
+ .string()
558
+ .optional()
559
+ .describe("Filter by organization ID. Pass the literal string 'null' to list only global personas."),
560
+ include_globals: z
561
+ .boolean()
562
+ .optional()
563
+ .describe("If true (default), include global personas (organizationId === null) alongside org-scoped ones."),
564
+ verbose: z
565
+ .boolean()
566
+ .optional()
567
+ .describe("If true, return full persona objects with every field. Defaults to false (compact view: id, name, organizationId, short description)."),
568
+ }, async ({ organization_id, include_globals, verbose }) => {
569
+ try {
570
+ const params = new URLSearchParams();
571
+ if (organization_id !== undefined)
572
+ params.set("organizationId", organization_id);
573
+ if (include_globals !== undefined)
574
+ params.set("includeGlobals", String(include_globals));
575
+ const data = await getClient().get("/api/persona", params);
576
+ if (verbose)
577
+ return toolResult(data);
578
+ // Project to compact view: id, name, organizationId, truncated description.
579
+ const personas = data && typeof data === "object" && "personas" in data
580
+ ? data.personas
581
+ : null;
582
+ if (!Array.isArray(personas))
583
+ return toolResult(data); // unexpected shape → fall through
584
+ const DESC_MAX = 200;
585
+ const compact = personas.map((p) => {
586
+ const obj = p;
587
+ const desc = typeof obj.description === "string" ? obj.description : undefined;
588
+ return {
589
+ id: obj.id,
590
+ name: obj.name,
591
+ organizationId: obj.organizationId ?? null,
592
+ ...(desc !== undefined && {
593
+ description: desc.length > DESC_MAX ? `${desc.slice(0, DESC_MAX)}…` : desc,
594
+ }),
595
+ };
596
+ });
597
+ return toolResult({
598
+ personas: compact,
599
+ count: compact.length,
600
+ _note: "Compact view. Call get_persona(id) for full details, or list_personas with verbose=true.",
601
+ });
602
+ }
603
+ catch (error) {
604
+ return handlePersonaToolError(error);
605
+ }
606
+ });
607
+ // ── delete_persona ─────────────────────────────────────────────────────
608
+ server.tool("delete_persona", "Permanently delete a persona by ID. Irreversible. Fails if the persona is still assigned " +
609
+ "to any lead group or referenced by an active campaign. Always confirm with the user " +
610
+ "before calling this tool.", {
611
+ id: z.string().describe("Persona ID to delete."),
612
+ }, async ({ id }) => {
613
+ try {
614
+ const data = await getClient().delete(`/api/persona/${encodeURIComponent(id)}`);
615
+ return toolResult(data);
616
+ }
617
+ catch (error) {
618
+ return handlePersonaToolError(error);
619
+ }
620
+ });
621
+ // ── move_persona ───────────────────────────────────────────────────────
622
+ server.tool("move_persona", "Move a persona to a different organization (or make it global by passing null). " +
623
+ "Admin permission required. Useful when promoting an org-scoped persona to a shared " +
624
+ "global template, or reassigning between orgs.", {
625
+ id: z.string().describe("Persona ID to move."),
626
+ organization_id: z
627
+ .string()
628
+ .nullable()
629
+ .describe("Target organization ID (Clerk org ID). Pass null to make the persona global."),
630
+ }, async ({ id, organization_id }) => {
631
+ try {
632
+ const data = await getClient().post(`/api/persona/${encodeURIComponent(id)}/move`, { organizationId: organization_id });
633
+ return toolResult(data);
634
+ }
635
+ catch (error) {
636
+ return handlePersonaToolError(error);
637
+ }
638
+ });
639
+ // ════════════════════════════════════════════════════════════════════════
640
+ // GRANULAR PATCH TOOLS — read-modify-write, scoped to one section each
641
+ // ════════════════════════════════════════════════════════════════════════
642
+ // ── update_persona_identity ────────────────────────────────────────────
643
+ server.tool("update_persona_identity", "Patch top-level scalar fields of a persona (name, description, agent_domain, " +
644
+ "output_language, workflow_mode, strict_match, enable_signals, sender_override). " +
645
+ "Pass only the fields you want to change. Other persona sections are untouched.", {
646
+ id: z.string().describe("Persona ID."),
647
+ name: z.string().min(1).optional().describe("New persona name."),
648
+ description: z.string().nullable().optional().describe("Detailed description."),
649
+ agent_domain: z
650
+ .string()
651
+ .nullable()
652
+ .optional()
653
+ .describe("Agent identity string (e.g. 'for medtech companies')."),
654
+ output_language: z
655
+ .string()
656
+ .nullable()
657
+ .optional()
658
+ .describe("Output language code (fr, en, de, …)."),
659
+ workflow_mode: z
660
+ .enum(["company_first", "person_first"])
661
+ .nullable()
662
+ .optional()
663
+ .describe("Qualification mode."),
664
+ strict_match: z
665
+ .boolean()
666
+ .optional()
667
+ .describe("If true, reject non-perfectly-aligned leads."),
668
+ enable_signals: z
669
+ .boolean()
670
+ .optional()
671
+ .describe("Enable business signal detection for this persona."),
672
+ sender_override: senderOverride
673
+ .nullable()
674
+ .optional()
675
+ .describe("Override SDR identity at persona level ({name, title}). Pass null to clear."),
676
+ }, async ({ id, name, description, agent_domain, output_language, workflow_mode, strict_match, enable_signals, sender_override, }) => {
677
+ try {
678
+ if (name === undefined &&
679
+ description === undefined &&
680
+ agent_domain === undefined &&
681
+ output_language === undefined &&
682
+ workflow_mode === undefined &&
683
+ strict_match === undefined &&
684
+ enable_signals === undefined &&
685
+ sender_override === undefined) {
686
+ return toolError("Provide at least one field to update.");
687
+ }
688
+ const current = await fetchPersona(id);
689
+ const body = {};
690
+ if (description !== undefined)
691
+ body.description = description;
692
+ if (agent_domain !== undefined)
693
+ body.agentDomain = agent_domain;
694
+ if (output_language !== undefined)
695
+ body.outputLanguage = output_language;
696
+ if (workflow_mode !== undefined)
697
+ body.workflowMode = workflow_mode;
698
+ if (strict_match !== undefined)
699
+ body.strictMatch = strict_match;
700
+ if (enable_signals !== undefined)
701
+ body.enableSignals = enable_signals;
702
+ if (sender_override !== undefined)
703
+ body.senderOverride = sender_override;
704
+ const data = await postPersonaUpdate(id, name ?? current.name, body);
705
+ return toolResult(data);
706
+ }
707
+ catch (error) {
708
+ return handlePersonaToolError(error);
709
+ }
710
+ });
711
+ // ── update_persona_target_profile ──────────────────────────────────────
712
+ server.tool("update_persona_target_profile", "Patch the targetProfile JSON column. Reads current targetProfile, shallow-merges " +
713
+ "the keys you pass, optionally removes specified keys, then writes back. Common " +
714
+ "keys: job_titles, specialties, seniority_min, decision_maker, buying_power, " +
715
+ "target_company_types, min_company_size, interest_topics. Other targetProfile " +
716
+ "keys remain intact.", {
717
+ id: z.string().describe("Persona ID."),
718
+ patch: z
719
+ .record(z.unknown())
720
+ .optional()
721
+ .describe("Object whose keys are merged into the existing targetProfile (shallow). Keys you don't pass remain intact."),
722
+ remove_keys: z
723
+ .array(z.string())
724
+ .optional()
725
+ .describe("Keys to delete from targetProfile."),
726
+ }, async ({ id, patch, remove_keys }) => {
727
+ try {
728
+ if ((patch === undefined || Object.keys(patch).length === 0) &&
729
+ (remove_keys === undefined || remove_keys.length === 0)) {
730
+ return toolError("Provide at least one of: patch (non-empty), remove_keys (non-empty).");
731
+ }
732
+ const current = await fetchPersona(id);
733
+ const next = { ...asObject(current.targetProfile), ...(patch ?? {}) };
734
+ for (const k of remove_keys ?? [])
735
+ delete next[k];
736
+ const data = await postPersonaUpdate(id, current.name, { targetProfile: next });
737
+ return toolResult(data);
738
+ }
739
+ catch (error) {
740
+ return handlePersonaToolError(error);
741
+ }
742
+ });
743
+ // ── update_persona_disqualification ────────────────────────────────────
744
+ server.tool("update_persona_disqualification", "Patch the disqualificationCriteria JSON column (shallow merge). Common key: " +
745
+ "reasons (array of strings). Other keys remain intact.", {
746
+ id: z.string().describe("Persona ID."),
747
+ patch: z
748
+ .record(z.unknown())
749
+ .optional()
750
+ .describe("Object whose keys are merged into existing disqualificationCriteria (shallow)."),
751
+ remove_keys: z
752
+ .array(z.string())
753
+ .optional()
754
+ .describe("Keys to delete from disqualificationCriteria."),
755
+ }, async ({ id, patch, remove_keys }) => {
756
+ try {
757
+ if ((patch === undefined || Object.keys(patch).length === 0) &&
758
+ (remove_keys === undefined || remove_keys.length === 0)) {
759
+ return toolError("Provide at least one of: patch (non-empty), remove_keys (non-empty).");
760
+ }
761
+ const current = await fetchPersona(id);
762
+ const next = { ...asObject(current.disqualificationCriteria), ...(patch ?? {}) };
763
+ for (const k of remove_keys ?? [])
764
+ delete next[k];
765
+ const data = await postPersonaUpdate(id, current.name, { disqualificationCriteria: next });
766
+ return toolResult(data);
767
+ }
768
+ catch (error) {
769
+ return handlePersonaToolError(error);
770
+ }
771
+ });
772
+ // ── update_persona_messaging ───────────────────────────────────────────
773
+ server.tool("update_persona_messaging", "Patch the messagingGuidelines JSON column (shallow merge). Common keys: tone, " +
774
+ "language, can_mention, must_not_mention, value_propositions. Other keys remain intact.", {
775
+ id: z.string().describe("Persona ID."),
776
+ patch: z
777
+ .record(z.unknown())
778
+ .optional()
779
+ .describe("Object whose keys are merged into existing messagingGuidelines (shallow)."),
780
+ remove_keys: z
781
+ .array(z.string())
782
+ .optional()
783
+ .describe("Keys to delete from messagingGuidelines."),
784
+ }, async ({ id, patch, remove_keys }) => {
785
+ try {
786
+ if ((patch === undefined || Object.keys(patch).length === 0) &&
787
+ (remove_keys === undefined || remove_keys.length === 0)) {
788
+ return toolError("Provide at least one of: patch (non-empty), remove_keys (non-empty).");
789
+ }
790
+ const current = await fetchPersona(id);
791
+ const next = { ...asObject(current.messagingGuidelines), ...(patch ?? {}) };
792
+ for (const k of remove_keys ?? [])
793
+ delete next[k];
794
+ const data = await postPersonaUpdate(id, current.name, { messagingGuidelines: next });
795
+ return toolResult(data);
796
+ }
797
+ catch (error) {
798
+ return handlePersonaToolError(error);
799
+ }
800
+ });
801
+ // ── update_persona_active_tools ────────────────────────────────────────
802
+ server.tool("update_persona_active_tools", "Replace the activeTools list wholesale, OR add/remove specific entries while " +
803
+ "preserving the rest. Use 'tools' to fully replace; use 'add' / 'remove' for " +
804
+ "incremental edits (deduped).", {
805
+ id: z.string().describe("Persona ID."),
806
+ tools: z
807
+ .array(z.string())
808
+ .optional()
809
+ .describe("Full replacement list. Mutually exclusive with add/remove."),
810
+ add: z
811
+ .array(z.string())
812
+ .optional()
813
+ .describe("Tools to add (no-op if already present)."),
814
+ remove: z
815
+ .array(z.string())
816
+ .optional()
817
+ .describe("Tools to remove (no-op if absent)."),
818
+ }, async ({ id, tools, add, remove }) => {
819
+ try {
820
+ if (tools === undefined && add === undefined && remove === undefined) {
821
+ return toolError("Provide one of: tools, add, remove.");
822
+ }
823
+ if (tools !== undefined && (add !== undefined || remove !== undefined)) {
824
+ return toolError("'tools' is mutually exclusive with 'add' / 'remove'.");
825
+ }
826
+ const current = await fetchPersona(id);
827
+ let next;
828
+ if (tools !== undefined) {
829
+ next = tools;
830
+ }
831
+ else {
832
+ const set = new Set(asArray(current.activeTools));
833
+ for (const t of add ?? [])
834
+ set.add(t);
835
+ for (const t of remove ?? [])
836
+ set.delete(t);
837
+ next = Array.from(set);
838
+ }
839
+ const data = await postPersonaUpdate(id, current.name, { activeTools: next });
840
+ return toolResult(data);
841
+ }
842
+ catch (error) {
843
+ return handlePersonaToolError(error);
844
+ }
845
+ });
846
+ // ── update_persona_tier_criteria ───────────────────────────────────────
847
+ server.tool("update_persona_tier_criteria", "Patch a single qualification tier (hot, warm, cold, or disqualified) without " +
848
+ "touching the other three. Each tier accepts either a free-form string or " +
849
+ "{criteria, examples, weight}. When passing a structured patch, sub-fields are " +
850
+ "merged into the existing structured tier (if any) — pass only what you want " +
851
+ "to change. Passing a string REPLACES the tier entirely.", {
852
+ id: z.string().describe("Persona ID."),
853
+ tier: z
854
+ .enum(["hot", "warm", "cold", "disqualified"])
855
+ .describe("Which tier to patch."),
856
+ criteria: z
857
+ .union([
858
+ z.string(),
859
+ z
860
+ .object({
861
+ criteria: z.string().optional(),
862
+ examples: z.string().optional(),
863
+ weight: z.number().optional(),
864
+ })
865
+ .passthrough(),
866
+ ])
867
+ .describe("New tier value. String replaces wholesale; object is merged."),
868
+ }, async ({ id, tier, criteria }) => {
869
+ try {
870
+ const fieldMap = {
871
+ hot: "hotCriteria",
872
+ warm: "warmCriteria",
873
+ cold: "coldCriteria",
874
+ disqualified: "disqualifiedCriteria",
875
+ };
876
+ const field = fieldMap[tier];
877
+ const current = await fetchPersona(id);
878
+ let nextValue;
879
+ if (typeof criteria === "string") {
880
+ nextValue = criteria;
881
+ }
882
+ else {
883
+ const existing = current[field];
884
+ const base = existing && typeof existing === "object" && !Array.isArray(existing)
885
+ ? existing
886
+ : {};
887
+ nextValue = { ...base, ...criteria };
888
+ }
889
+ const data = await postPersonaUpdate(id, current.name, { [field]: nextValue });
890
+ return toolResult(data);
891
+ }
892
+ catch (error) {
893
+ return handlePersonaToolError(error);
894
+ }
895
+ });
896
+ // ── update_persona_lookalike_client ────────────────────────────────────
897
+ server.tool("update_persona_lookalike_client", "Add, replace, or remove a single lookalike client without re-sending the whole " +
898
+ "list. Lookups are by client `name` (case-sensitive exact match). Reads the " +
899
+ "current lookalikeClients array, mutates the matched entry, writes the full " +
900
+ "list back. Use this instead of upsert_persona to avoid wiping the others.", {
901
+ id: z.string().describe("Persona ID."),
902
+ action: z
903
+ .enum(["add", "replace", "remove"])
904
+ .describe("add = append (fails if name already exists); replace = overwrite by name; remove = delete by name."),
905
+ name: z
906
+ .string()
907
+ .optional()
908
+ .describe("Lookup key for replace/remove. Required for those actions. For 'add', the name is taken from the client payload."),
909
+ client: lookalikeClient
910
+ .optional()
911
+ .describe("Client payload. Required for 'add' and 'replace'. Schema: {name, sector?, size?, segment?, comparison_criteria?}."),
912
+ }, async ({ id, action, name, client }) => {
913
+ try {
914
+ if ((action === "add" || action === "replace") && !client) {
915
+ return toolError(`client is required for action '${action}'.`);
916
+ }
917
+ if ((action === "replace" || action === "remove") && !name) {
918
+ return toolError(`name is required for action '${action}'.`);
919
+ }
920
+ const current = await fetchPersona(id);
921
+ const list = asArray(current.lookalikeClients).map((c) => ({ ...c }));
922
+ if (action === "add") {
923
+ const newName = client.name;
924
+ if (list.some((c) => c.name === newName)) {
925
+ return toolError(`A lookalike client with name "${newName}" already exists. Use action 'replace' instead.`);
926
+ }
927
+ list.push(mapLookalikeClient(client));
928
+ }
929
+ else if (action === "replace") {
930
+ const idx = list.findIndex((c) => c.name === name);
931
+ if (idx === -1) {
932
+ return toolError(`No lookalike client found with name "${name}".`);
933
+ }
934
+ list[idx] = mapLookalikeClient(client);
935
+ }
936
+ else {
937
+ const before = list.length;
938
+ const filtered = list.filter((c) => c.name !== name);
939
+ if (filtered.length === before) {
940
+ return toolError(`No lookalike client found with name "${name}".`);
941
+ }
942
+ list.length = 0;
943
+ list.push(...filtered);
944
+ }
945
+ const data = await postPersonaUpdate(id, current.name, {
946
+ lookalikeClients: list,
947
+ });
948
+ return toolResult(data);
949
+ }
950
+ catch (error) {
951
+ return handlePersonaToolError(error);
952
+ }
953
+ });
954
+ // ── update_persona_pain_point ──────────────────────────────────────────
955
+ server.tool("update_persona_pain_point", "Add, replace, or remove a single pain point without re-sending the whole list. " +
956
+ "Pain points have no stable id — entries are addressed by zero-based index. " +
957
+ "Beware: indices shift after a remove, so re-fetch before chaining edits.", {
958
+ id: z.string().describe("Persona ID."),
959
+ action: z
960
+ .enum(["add", "replace", "remove"])
961
+ .describe("add = append; replace = overwrite at index; remove = delete at index."),
962
+ index: z
963
+ .number()
964
+ .int()
965
+ .nonnegative()
966
+ .optional()
967
+ .describe("Zero-based index. Required for 'replace' and 'remove'."),
968
+ pain: painPoint
969
+ .optional()
970
+ .describe("Pain payload {title?, description?}. Required for 'add' and 'replace'."),
971
+ }, async ({ id, action, index, pain }) => {
972
+ try {
973
+ if ((action === "add" || action === "replace") && !pain) {
974
+ return toolError(`pain is required for action '${action}'.`);
975
+ }
976
+ if ((action === "replace" || action === "remove") && index === undefined) {
977
+ return toolError(`index is required for action '${action}'.`);
978
+ }
979
+ const current = await fetchPersona(id);
980
+ const list = asArray(current.painPoints).map((p) => ({ ...p }));
981
+ if (action === "add") {
982
+ list.push(pain);
983
+ }
984
+ else if (action === "replace") {
985
+ if (index < 0 || index >= list.length) {
986
+ return toolError(`Index ${index} out of bounds (list has ${list.length} entries).`);
987
+ }
988
+ list[index] = pain;
989
+ }
990
+ else {
991
+ if (index < 0 || index >= list.length) {
992
+ return toolError(`Index ${index} out of bounds (list has ${list.length} entries).`);
993
+ }
994
+ list.splice(index, 1);
995
+ }
996
+ const data = await postPersonaUpdate(id, current.name, { painPoints: list });
997
+ return toolResult(data);
998
+ }
999
+ catch (error) {
1000
+ return handlePersonaToolError(error);
1001
+ }
1002
+ });
1003
+ // ── update_persona_outreach_template ───────────────────────────────────
1004
+ server.tool("update_persona_outreach_template", "Add, replace, or remove a single outreach template without re-sending the whole " +
1005
+ "list. Lookups are by template `id` (recommended) or by composite key " +
1006
+ "(channel + signal + field_name) when no id is set. WARNING: if the persona's " +
1007
+ "outreachSignalRouting.signals[] is configured, every template's `signal` must " +
1008
+ "be in that list — the API rejects with 422 otherwise.", {
1009
+ id: z.string().describe("Persona ID."),
1010
+ action: z
1011
+ .enum(["add", "replace", "remove"])
1012
+ .describe("add = append; replace = overwrite by id (or composite key); remove = delete by id (or composite key)."),
1013
+ template_id: z
1014
+ .string()
1015
+ .optional()
1016
+ .describe("Template id for replace/remove. Preferred lookup key."),
1017
+ lookup_channel: z
1018
+ .string()
1019
+ .optional()
1020
+ .describe("Fallback lookup: channel. Used together with lookup_signal + lookup_field_name when template_id is absent."),
1021
+ lookup_signal: z
1022
+ .string()
1023
+ .optional()
1024
+ .describe("Fallback lookup: signal."),
1025
+ lookup_field_name: z
1026
+ .string()
1027
+ .optional()
1028
+ .describe("Fallback lookup: field_name."),
1029
+ template: outreachTemplate
1030
+ .optional()
1031
+ .describe("Template payload. Required for 'add' and 'replace'. Schema: {id, channel, signal, field_name, signal_context, purpose, example, max_length, label, subject, body}."),
1032
+ }, async ({ id, action, template_id, lookup_channel, lookup_signal, lookup_field_name, template, }) => {
1033
+ try {
1034
+ if ((action === "add" || action === "replace") && !template) {
1035
+ return toolError(`template is required for action '${action}'.`);
1036
+ }
1037
+ const matches = (t) => {
1038
+ if (template_id)
1039
+ return t.id === template_id;
1040
+ if (lookup_channel || lookup_signal || lookup_field_name) {
1041
+ return ((lookup_channel === undefined || t.channel === lookup_channel) &&
1042
+ (lookup_signal === undefined || t.signal === lookup_signal) &&
1043
+ (lookup_field_name === undefined || t.fieldName === lookup_field_name));
1044
+ }
1045
+ return false;
1046
+ };
1047
+ if (action !== "add" && !template_id && !lookup_channel && !lookup_signal && !lookup_field_name) {
1048
+ return toolError("Provide template_id (preferred) or composite lookup (lookup_channel + lookup_signal + lookup_field_name).");
1049
+ }
1050
+ const current = await fetchPersona(id);
1051
+ const list = asArray(current.outreachTemplates).map((t) => ({ ...t }));
1052
+ if (action === "add") {
1053
+ list.push(mapOutreachTemplate(template));
1054
+ }
1055
+ else if (action === "replace") {
1056
+ const idx = list.findIndex(matches);
1057
+ if (idx === -1) {
1058
+ return toolError("No matching template found for the given lookup.");
1059
+ }
1060
+ list[idx] = mapOutreachTemplate(template);
1061
+ }
1062
+ else {
1063
+ const before = list.length;
1064
+ const filtered = list.filter((t) => !matches(t));
1065
+ if (filtered.length === before) {
1066
+ return toolError("No matching template found for the given lookup.");
1067
+ }
1068
+ list.length = 0;
1069
+ list.push(...filtered);
1070
+ }
1071
+ const data = await postPersonaUpdate(id, current.name, {
1072
+ outreachTemplates: list,
1073
+ });
1074
+ return toolResult(data);
1075
+ }
1076
+ catch (error) {
1077
+ return handlePersonaToolError(error);
1078
+ }
1079
+ });
1080
+ // ── update_persona_outreach_signal_routing ─────────────────────────────
1081
+ server.tool("update_persona_outreach_signal_routing", "Patch the outreachSignalRouting block. signal_source_field and default_signal " +
1082
+ "are scalar — passing one replaces it. signals[] is replaced wholesale when " +
1083
+ "passed; use add_signals / remove_signals to splice instead. " +
1084
+ "WARNING: default_signal must be present in signals[] (API rejects otherwise). " +
1085
+ "Removing a signal that's still referenced by an outreachTemplate.signal will " +
1086
+ "also be rejected.", {
1087
+ id: z.string().describe("Persona ID."),
1088
+ signal_source_field: z
1089
+ .string()
1090
+ .optional()
1091
+ .describe("Card field to read (e.g. card_signals)."),
1092
+ default_signal: z
1093
+ .string()
1094
+ .optional()
1095
+ .describe("Default signal — must exist in signals[]."),
1096
+ signals: z
1097
+ .array(z.string())
1098
+ .optional()
1099
+ .describe("Full replacement of the signals list. Mutually exclusive with add_signals / remove_signals."),
1100
+ add_signals: z
1101
+ .array(z.string())
1102
+ .optional()
1103
+ .describe("Signals to add (deduped). Mutually exclusive with signals."),
1104
+ remove_signals: z
1105
+ .array(z.string())
1106
+ .optional()
1107
+ .describe("Signals to remove. Mutually exclusive with signals."),
1108
+ }, async ({ id, signal_source_field, default_signal, signals, add_signals, remove_signals, }) => {
1109
+ try {
1110
+ if (signal_source_field === undefined &&
1111
+ default_signal === undefined &&
1112
+ signals === undefined &&
1113
+ add_signals === undefined &&
1114
+ remove_signals === undefined) {
1115
+ return toolError("Provide at least one field to update.");
1116
+ }
1117
+ if (signals !== undefined &&
1118
+ (add_signals !== undefined || remove_signals !== undefined)) {
1119
+ return toolError("'signals' is mutually exclusive with add_signals / remove_signals.");
1120
+ }
1121
+ const current = await fetchPersona(id);
1122
+ const existing = asObject(current.outreachSignalRouting);
1123
+ const next = { ...existing };
1124
+ if (signal_source_field !== undefined)
1125
+ next.signalSourceField = signal_source_field;
1126
+ if (default_signal !== undefined)
1127
+ next.defaultSignal = default_signal;
1128
+ if (signals !== undefined) {
1129
+ next.signals = signals;
1130
+ }
1131
+ else if (add_signals !== undefined || remove_signals !== undefined) {
1132
+ const set = new Set(asArray(existing.signals));
1133
+ for (const s of add_signals ?? [])
1134
+ set.add(s);
1135
+ for (const s of remove_signals ?? [])
1136
+ set.delete(s);
1137
+ next.signals = Array.from(set);
1138
+ }
1139
+ const data = await postPersonaUpdate(id, current.name, {
1140
+ outreachSignalRouting: next,
1141
+ });
1142
+ return toolResult(data);
1143
+ }
1144
+ catch (error) {
1145
+ return handlePersonaToolError(error);
1146
+ }
1147
+ });
1148
+ // ── update_persona_prompt_engineering ──────────────────────────────────
1149
+ server.tool("update_persona_prompt_engineering", "Patch free-form markdown prompt fields (normalization_rules, signal_guidance, " +
1150
+ "tone_instructions, context). Each is a separate scalar JSON column — passing " +
1151
+ "one replaces it (pass null to clear). Other prompt fields and persona sections " +
1152
+ "remain intact.", {
1153
+ id: z.string().describe("Persona ID."),
1154
+ normalization_rules: z
1155
+ .string()
1156
+ .nullable()
1157
+ .optional()
1158
+ .describe("Markdown injected into the ICP prompt. Null clears it."),
1159
+ signal_guidance: z
1160
+ .string()
1161
+ .nullable()
1162
+ .optional()
1163
+ .describe("Markdown injected into the signal prompt. Null clears it."),
1164
+ tone_instructions: z
1165
+ .string()
1166
+ .nullable()
1167
+ .optional()
1168
+ .describe("Markdown for voice / tone guidance. Null clears it."),
1169
+ context: z
1170
+ .string()
1171
+ .nullable()
1172
+ .optional()
1173
+ .describe("Markdown appended to the ICP prompt. Null clears it."),
1174
+ }, async ({ id, normalization_rules, signal_guidance, tone_instructions, context, }) => {
1175
+ try {
1176
+ if (normalization_rules === undefined &&
1177
+ signal_guidance === undefined &&
1178
+ tone_instructions === undefined &&
1179
+ context === undefined) {
1180
+ return toolError("Provide at least one field to update.");
1181
+ }
1182
+ const current = await fetchPersona(id);
1183
+ const body = {};
1184
+ if (normalization_rules !== undefined)
1185
+ body.normalizationRules = normalization_rules;
1186
+ if (signal_guidance !== undefined)
1187
+ body.signalGuidance = signal_guidance;
1188
+ if (tone_instructions !== undefined)
1189
+ body.toneInstructions = tone_instructions;
1190
+ if (context !== undefined)
1191
+ body.context = context;
1192
+ const data = await postPersonaUpdate(id, current.name, body);
1193
+ return toolResult(data);
1194
+ }
1195
+ catch (error) {
1196
+ return handlePersonaToolError(error);
1197
+ }
1198
+ });
1199
+ // ── update_persona_icp_strategy ────────────────────────────────────────
1200
+ server.tool("update_persona_icp_strategy", "Patch the icpStrategy JSON column. Mental map consumed verbatim by the outreach " +
1201
+ "agent preflight: {dealType, dreamOutcome, mainPains[], keyObjections[], " +
1202
+ "decisionDrivers[]}. Reads current icpStrategy, shallow-merges the keys you " +
1203
+ "pass, optionally removes specified keys. Arrays (mainPains, keyObjections, " +
1204
+ "decisionDrivers) are REPLACED wholesale when passed.", {
1205
+ id: z.string().describe("Persona ID."),
1206
+ dealType: z
1207
+ .string()
1208
+ .optional()
1209
+ .describe("Type of deal (e.g. 'midmarket', 'enterprise', 'self-serve')."),
1210
+ dreamOutcome: z
1211
+ .string()
1212
+ .optional()
1213
+ .describe("The aspirational result the lead wants."),
1214
+ mainPains: z
1215
+ .array(z.string())
1216
+ .optional()
1217
+ .describe("Replaces the mainPains array wholesale when passed."),
1218
+ keyObjections: z
1219
+ .array(z.string())
1220
+ .optional()
1221
+ .describe("Replaces the keyObjections array wholesale when passed."),
1222
+ decisionDrivers: z
1223
+ .array(z.string())
1224
+ .optional()
1225
+ .describe("Replaces the decisionDrivers array wholesale when passed."),
1226
+ patch: z
1227
+ .record(z.unknown())
1228
+ .optional()
1229
+ .describe("Escape hatch for any additional/forward-compat keys — shallow-merged after the named fields."),
1230
+ remove_keys: z
1231
+ .array(z.string())
1232
+ .optional()
1233
+ .describe("Keys to delete from icpStrategy."),
1234
+ }, async ({ id, dealType, dreamOutcome, mainPains, keyObjections, decisionDrivers, patch, remove_keys, }) => {
1235
+ try {
1236
+ if (dealType === undefined &&
1237
+ dreamOutcome === undefined &&
1238
+ mainPains === undefined &&
1239
+ keyObjections === undefined &&
1240
+ decisionDrivers === undefined &&
1241
+ (patch === undefined || Object.keys(patch).length === 0) &&
1242
+ (remove_keys === undefined || remove_keys.length === 0)) {
1243
+ return toolError("Provide at least one field to update.");
1244
+ }
1245
+ const current = await fetchPersona(id);
1246
+ const next = { ...asObject(current.icpStrategy) };
1247
+ if (dealType !== undefined)
1248
+ next.dealType = dealType;
1249
+ if (dreamOutcome !== undefined)
1250
+ next.dreamOutcome = dreamOutcome;
1251
+ if (mainPains !== undefined)
1252
+ next.mainPains = mainPains;
1253
+ if (keyObjections !== undefined)
1254
+ next.keyObjections = keyObjections;
1255
+ if (decisionDrivers !== undefined)
1256
+ next.decisionDrivers = decisionDrivers;
1257
+ if (patch !== undefined)
1258
+ Object.assign(next, patch);
1259
+ for (const k of remove_keys ?? [])
1260
+ delete next[k];
1261
+ const data = await postPersonaUpdate(id, current.name, { icpStrategy: next });
1262
+ return toolResult(data);
1263
+ }
1264
+ catch (error) {
1265
+ return handlePersonaToolError(error);
1266
+ }
1267
+ });
1268
+ }