@apex-inc/mcp-server 0.32.0 → 0.34.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.
Files changed (74) hide show
  1. package/README.md +19 -18
  2. package/dist/api-client.d.ts +5 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +40 -24
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/conflict-advisory.d.ts +29 -0
  7. package/dist/conflict-advisory.d.ts.map +1 -0
  8. package/dist/conflict-advisory.js +48 -0
  9. package/dist/conflict-advisory.js.map +1 -0
  10. package/dist/index.js +8 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/live-test-guard.d.ts +1 -0
  13. package/dist/live-test-guard.d.ts.map +1 -1
  14. package/dist/live-test-guard.js +4 -3
  15. package/dist/live-test-guard.js.map +1 -1
  16. package/dist/prompts.d.ts.map +1 -1
  17. package/dist/prompts.js +5 -7
  18. package/dist/prompts.js.map +1 -1
  19. package/dist/setup-catchup-tools.d.ts.map +1 -1
  20. package/dist/setup-catchup-tools.js +15 -3
  21. package/dist/setup-catchup-tools.js.map +1 -1
  22. package/dist/smoke-tools.d.ts +56 -0
  23. package/dist/smoke-tools.d.ts.map +1 -0
  24. package/dist/smoke-tools.js +261 -0
  25. package/dist/smoke-tools.js.map +1 -0
  26. package/dist/tool-catalog.d.ts +10 -0
  27. package/dist/tool-catalog.d.ts.map +1 -0
  28. package/dist/tool-catalog.js +36 -0
  29. package/dist/tool-catalog.js.map +1 -0
  30. package/dist/tool-groups.d.ts +5 -0
  31. package/dist/tool-groups.d.ts.map +1 -0
  32. package/dist/tool-groups.js +206 -0
  33. package/dist/tool-groups.js.map +1 -0
  34. package/dist/tool-shared.d.ts +300 -0
  35. package/dist/tool-shared.d.ts.map +1 -0
  36. package/dist/tool-shared.js +319 -0
  37. package/dist/tool-shared.js.map +1 -0
  38. package/dist/tools/accounts.d.ts +124 -0
  39. package/dist/tools/accounts.d.ts.map +1 -0
  40. package/dist/tools/accounts.js +99 -0
  41. package/dist/tools/accounts.js.map +1 -0
  42. package/dist/tools/adoption.d.ts +499 -0
  43. package/dist/tools/adoption.d.ts.map +1 -0
  44. package/dist/tools/adoption.js +447 -0
  45. package/dist/tools/adoption.js.map +1 -0
  46. package/dist/tools/context.d.ts +216 -0
  47. package/dist/tools/context.d.ts.map +1 -0
  48. package/dist/tools/context.js +328 -0
  49. package/dist/tools/context.js.map +1 -0
  50. package/dist/tools/experiments.d.ts +1066 -0
  51. package/dist/tools/experiments.d.ts.map +1 -0
  52. package/dist/tools/experiments.js +2090 -0
  53. package/dist/tools/experiments.js.map +1 -0
  54. package/dist/tools/letters.d.ts +1107 -0
  55. package/dist/tools/letters.d.ts.map +1 -0
  56. package/dist/tools/letters.js +1324 -0
  57. package/dist/tools/letters.js.map +1 -0
  58. package/dist/tools/measurement.d.ts +886 -0
  59. package/dist/tools/measurement.d.ts.map +1 -0
  60. package/dist/tools/measurement.js +1353 -0
  61. package/dist/tools/measurement.js.map +1 -0
  62. package/dist/tools/partners.d.ts +364 -0
  63. package/dist/tools/partners.d.ts.map +1 -0
  64. package/dist/tools/partners.js +199 -0
  65. package/dist/tools/partners.js.map +1 -0
  66. package/dist/tools.d.ts +3161 -2826
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +26 -6133
  69. package/dist/tools.js.map +1 -1
  70. package/package.json +3 -2
  71. package/skills/apex-communications/SKILL.md +5 -5
  72. package/skills/apex-experimentation/SKILL.md +11 -14
  73. package/skills/apex-growth-intelligence/SKILL.md +1 -1
  74. package/skills/apex-integration-cookbook/SKILL.md +1 -1
@@ -0,0 +1,1324 @@
1
+ import { z } from "zod";
2
+ import { apiGet, apiPost, apiPatch, apiDelete, getActiveWorkspace, } from "../api-client.js";
3
+ import { controlGateGuidanceFor } from "../control-gate-guidance.js";
4
+ import { notStartedExplanation, publishedExperimentLine } from "../publish-communication-copy.js";
5
+ import { applyAddJourneyStep } from "../add-journey-step.js";
6
+ import { refuseLiveApexObject } from "../live-test-guard.js";
7
+ import { APEX, errMsg, appUrl, journeyLink, tenantOk, tenantMismatch, } from "../tool-shared.js";
8
+ export const letterTools = {
9
+ recommend_communications: {
10
+ description: "Get recommended lifecycle communications for your vertical and conversion model. Returns a prioritized list of emails and push notifications to set up.",
11
+ schema: z.object({}),
12
+ handler: async () => {
13
+ const data = await apiPost("/api/communications/recommend", {});
14
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
15
+ },
16
+ },
17
+ generate_communications: {
18
+ description: "Create your communication program from recommendations. Pass catalog entry IDs to generate tenant communications with pre-written copy. Each new letter wears the workspace default Theme (Title / Body / Button look is snapshotted; catalog copy is not rewritten to match). Preview with preview_communication to see theme roles vs block overrides.",
19
+ schema: z.object({
20
+ catalogEntryIds: z.array(z.string()).describe("IDs from the recommendation output to generate"),
21
+ }),
22
+ handler: async (args) => {
23
+ const data = await apiPost("/api/communications/generate", { catalogEntryIds: args.catalogEntryIds });
24
+ const count = Array.isArray(data) ? data.length : 0;
25
+ return { content: [{ type: "text", text: `Generated ${count} communication(s).\n\n${JSON.stringify(data, null, 2)}` }] };
26
+ },
27
+ },
28
+ list_communications: {
29
+ description: "List the workspace's communication templates (email / inbox / web push / mobile push) with the fields you need to CHOOSE one — for a journey send step, a broadcast, or a transactional send. Returns per template: title, version, status, pipeline, channels, subject, a one-line body preview so you can disambiguate similar titles without opening each one, and controlState. Pipeline is 'transactional' (bypasses opt-out — needs a recipient-initiated trigger) or 'marketing' (consent-gated). Filter by pipeline, channel, or a search string. This is the programmatic equivalent of the dashboard's communication picker.\n\ncontrolState tells you what the Control currently IS, and therefore what editing it will cost: 'winner' (won an experiment, unedited — editing needs an explicit intent), 'winner_edited' (won, then changed since), 'in_experiment' (frozen, an experiment is measuring it right now), 'no_winner' (an experiment ran and settled without one), 'none' (never experimented on). A live letter with no champion is still editable; Publish asks replace vs compete. A champion or a running test: do not edit Control — add a variant, then compete.",
30
+ schema: z.object({
31
+ pipeline: z
32
+ .enum(["transactional", "marketing"])
33
+ .optional()
34
+ .describe("Only return comms in this deliverability pipeline. 'transactional' bypasses marketing opt-out; 'marketing' is consent-gated."),
35
+ channel: z
36
+ .enum(["email", "inbox", "web_push", "mobile_push"])
37
+ .optional()
38
+ .describe("Only return comms that include this channel."),
39
+ search: z
40
+ .string()
41
+ .optional()
42
+ .describe("Case-insensitive match against title, subject, and body preview."),
43
+ includeArchived: z
44
+ .boolean()
45
+ .optional()
46
+ .describe("Include archived templates (default false)."),
47
+ }),
48
+ handler: async (args) => {
49
+ const path = args.includeArchived
50
+ ? "/api/communications?includeArchived=1"
51
+ : "/api/communications";
52
+ const data = await apiGet(path);
53
+ const raw = Array.isArray(data) ? data : [];
54
+ const rows = raw.map((c) => {
55
+ const comm = c;
56
+ const content = (comm.content ?? {});
57
+ const slots = (content.slots ?? {});
58
+ const commType = typeof comm.commType === "string" ? comm.commType : undefined;
59
+ const bodyPreview = slots.body || slots.headline || slots.paragraph_1 || "";
60
+ return {
61
+ id: String(comm.id ?? ""),
62
+ title: String(comm.title ?? "Untitled"),
63
+ version: typeof comm.version === "number" ? comm.version : 1,
64
+ status: String(comm.status ?? "draft"),
65
+ pipeline: commType === "transactional"
66
+ ? "transactional"
67
+ : "marketing",
68
+ channels: Array.isArray(comm.channels)
69
+ ? comm.channels
70
+ : ["email"],
71
+ subject: typeof content.subject === "string" ? content.subject : "",
72
+ bodyPreview: String(bodyPreview),
73
+ controlState: typeof comm.controlState === "string" ? comm.controlState : "none",
74
+ triggerEventId: typeof comm.triggerEventId === "string"
75
+ ? comm.triggerEventId
76
+ : "",
77
+ };
78
+ });
79
+ const q = (args.search ?? "").trim().toLowerCase();
80
+ const filtered = rows.filter((r) => {
81
+ if (args.pipeline && r.pipeline !== args.pipeline)
82
+ return false;
83
+ if (args.channel && !r.channels.includes(args.channel))
84
+ return false;
85
+ if (q &&
86
+ !`${r.title} ${r.subject} ${r.bodyPreview}`.toLowerCase().includes(q)) {
87
+ return false;
88
+ }
89
+ return true;
90
+ });
91
+ if (filtered.length === 0) {
92
+ const hint = rows.length === 0
93
+ ? "No communications set up yet. Use recommend_communications to get started."
94
+ : "No communications match those filters.";
95
+ return { content: [{ type: "text", text: hint }] };
96
+ }
97
+ const summary = filtered
98
+ .map((r) => {
99
+ const preview = r.bodyPreview
100
+ ? `\n ${r.bodyPreview.slice(0, 120)}`
101
+ : "";
102
+ const control = r.controlState && r.controlState !== "none"
103
+ ? ` — control: ${r.controlState}`
104
+ : "";
105
+ return `• ${r.title} (v${r.version}) — ${r.pipeline} — ${r.channels.join("/")} — ${r.status}${control}\n subject: ${r.subject || "(none)"}${preview}\n id: ${r.id}`;
106
+ })
107
+ .join("\n\n");
108
+ return {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: `${filtered.length} communication(s):\n\n${summary}\n\nJSON:\n${JSON.stringify(filtered, null, 2)}`,
113
+ },
114
+ ],
115
+ };
116
+ },
117
+ },
118
+ list_communication_themes: {
119
+ description: "List workspace communication Themes (the look: fonts, sizes, colors, Title/Body/Button roles). A Theme is not a Template — Templates are the job (event, slots, copy). Activate a Template with no extra theme step; new emails wear the workspace default theme. Apply writes drafts only; the merchant still publishes each email.",
120
+ schema: z.object({}),
121
+ handler: async () => {
122
+ const json = await apiGet("/api/communications/themes");
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text",
127
+ text: JSON.stringify(json, null, 2),
128
+ },
129
+ ],
130
+ };
131
+ },
132
+ },
133
+ create_communication_theme: {
134
+ description: "Create a communication Theme (the look). Clone an existing theme with clone_from_id, or pass style/roles. Does not assign other emails. Tell the merchant to publish emails after applying.",
135
+ schema: z.object({
136
+ name: z.string().min(1).max(60),
137
+ clone_from_id: z.string().optional(),
138
+ style: z.record(z.string(), z.unknown()).optional(),
139
+ roles: z.record(z.string(), z.unknown()).optional(),
140
+ }),
141
+ handler: async (args) => {
142
+ const data = await apiPost("/api/communications/themes", {
143
+ name: args.name,
144
+ cloneFromId: args.clone_from_id,
145
+ style: args.style,
146
+ roles: args.roles,
147
+ });
148
+ return {
149
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
150
+ };
151
+ },
152
+ },
153
+ update_communication_theme: {
154
+ description: "Update a communication Theme's name, style tokens, or Title/Body/Button roles. Does not rewrite other emails. Use apply_communication_theme to draft the look onto chosen emails. set_default makes new activations wear this theme.",
155
+ schema: z.object({
156
+ theme_id: z.string(),
157
+ name: z.string().min(1).max(60).optional(),
158
+ style: z.record(z.string(), z.unknown()).optional(),
159
+ roles: z.record(z.string(), z.unknown()).optional(),
160
+ set_default: z.boolean().optional(),
161
+ }),
162
+ handler: async (args) => {
163
+ const data = await apiPatch(`/api/communications/themes/${encodeURIComponent(args.theme_id)}`, {
164
+ name: args.name,
165
+ style: args.style,
166
+ roles: args.roles,
167
+ setDefault: args.set_default,
168
+ });
169
+ return {
170
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
171
+ };
172
+ },
173
+ },
174
+ apply_communication_theme: {
175
+ description: "Apply a Theme's look onto chosen communication Templates. Default apply is draft — recipients keep the old look until the merchant publishes. apply=live uses the look on the next send. Omit comm_ids to target every template already on this theme. Never group emails by experiment catalog ids; a shop's order-updates series is the same object.",
176
+ schema: z.object({
177
+ theme_id: z.string(),
178
+ comm_ids: z.array(z.string()).optional(),
179
+ apply: z.enum(["draft", "live"]).optional(),
180
+ }),
181
+ handler: async (args) => {
182
+ const data = await apiPost(`/api/communications/themes/${encodeURIComponent(args.theme_id)}/apply`, { commIds: args.comm_ids, apply: args.apply });
183
+ return {
184
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
185
+ };
186
+ },
187
+ },
188
+ edit_communication: {
189
+ description: "Update a communication's subject, body, CTA, channels, or status. Pass the communication ID and the fields to update. For blank/builder comms, the headline/body/ctaLabel/ctaUrl slots are synced into the rendered email body — verify with preview_communication.\n\nA communication's Control is protected when it won an experiment or a published journey is sending it. Editing it then requires `intent`: \"compete\" adds your edit as a variant and measures it against the current content (nothing changes for recipients yet); \"replace\" makes your edit what everyone receives and supersedes any prior win. WHEN IN DOUBT, USE \"compete\" — it is recoverable, \"replace\" discards a measured result permanently. The server returns 409 intent_required with both options if you omit it.",
190
+ schema: z.object({
191
+ communicationId: z.string().describe("The communication ID to update"),
192
+ subject: z.string().optional().describe("New email subject line"),
193
+ slots: z.record(z.string()).optional().describe("Content overrides: headline, body, ctaLabel, ctaUrl. These now sync into the rendered email body (not just metadata)."),
194
+ channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
195
+ status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
196
+ audience_bucket: z
197
+ .enum(["production", "beta", "dev"])
198
+ .optional()
199
+ .describe("Who this send is for. Default production. Never All or Unclassified."),
200
+ intent: z
201
+ .enum(["compete", "replace"])
202
+ .optional()
203
+ .describe('How to apply the edit when the Control is protected. "compete" = add it as a variant and run it as an experiment against the current content (safe default; the current content keeps sending). "replace" = update what everyone receives, superseding any prior win. Required only when the comm won an experiment or is live in a published journey.'),
204
+ confirmLiveExperiment: z
205
+ .boolean()
206
+ .optional()
207
+ .describe("Edit a communication that is the treatment in a RUNNING experiment. Marks the numbers untrustworthy: we will not call a winner and we will not learn from mixed counts. Only pass true when they picked an out."),
208
+ }),
209
+ handler: async (args) => {
210
+ const guard = await refuseLiveApexObject({
211
+ communicationIds: [args.communicationId],
212
+ confirmed: args.confirmLiveExperiment === true,
213
+ purpose: "draft",
214
+ });
215
+ if (guard.stop) {
216
+ return { content: [{ type: "text", text: guard.text }] };
217
+ }
218
+ const body = {};
219
+ if (args.status)
220
+ body.status = args.status;
221
+ if (args.channels)
222
+ body.channels = args.channels;
223
+ if (args.audience_bucket)
224
+ body.audienceBucket = args.audience_bucket;
225
+ if (args.intent)
226
+ body.intent = args.intent;
227
+ if (args.confirmLiveExperiment)
228
+ body.confirmLiveExperiment = true;
229
+ const content = {};
230
+ if (args.subject)
231
+ content.subject = args.subject;
232
+ if (args.slots)
233
+ content.slots = args.slots;
234
+ if (Object.keys(content).length)
235
+ body.content = content;
236
+ try {
237
+ const data = await apiPatch(`/api/communications/${args.communicationId}`, body);
238
+ // The comm may be protected without the edit being blocked (draft saves
239
+ // defer the question to publish). Carry the server's advisory through
240
+ // so the agent knows a decision is coming rather than meeting it later.
241
+ const advisory = typeof data?.controlAdvisory === "string"
242
+ ? `\n\n${data.controlAdvisory}`
243
+ : "";
244
+ return { content: [{ type: "text", text: `Communication updated.${advisory}\n\n${JSON.stringify(data, null, 2)}` }] };
245
+ }
246
+ catch (err) {
247
+ const guidance = controlGateGuidanceFor(err, {
248
+ toolName: "edit_communication",
249
+ communicationId: args.communicationId,
250
+ });
251
+ return {
252
+ content: [
253
+ {
254
+ type: "text",
255
+ text: guidance ?? `${APEX} ${err instanceof Error ? err.message : String(err)}`,
256
+ },
257
+ ],
258
+ isError: true,
259
+ };
260
+ }
261
+ },
262
+ },
263
+ publish_communication: {
264
+ description: "Publish a communication's draft as the live version — this is what makes an edit reach recipients. Editing only saves a draft.\n\nCompete (or publishing a letter that already has variants) creates a prefilled DRAFT experiment. It does not start the test. After the user reviews the draft, call activate_experiment to begin it. Replace publishes the letter for everyone and never creates an experiment.\n\nOptional overrides: hypothesis, belief_id, confidence, duration_days.\n\nTwo conflicts need a decision rather than a retry. `ambiguous_experiment_host` means several published journeys send this communication — the candidates are returned; ask the user and re-issue with host_journey_id. `comm_already_in_experiment` means one is already running; end it first.",
265
+ schema: z.object({
266
+ communicationId: z.string().describe("The communication ID to publish"),
267
+ intent: z
268
+ .enum(["compete", "replace"])
269
+ .optional()
270
+ .describe('How to apply the draft when the Control is protected. "compete" = the current content keeps sending and your draft becomes a filled-in experiment draft (then activate_experiment). "replace" = your draft becomes what everyone receives. Required only when the comm won an experiment or is live in a published journey.'),
271
+ confirmLiveExperiment: z
272
+ .boolean()
273
+ .optional()
274
+ .describe("Publish over a communication that is the treatment in a RUNNING experiment. Marks the numbers untrustworthy: we will not call a winner and we will not learn from mixed counts. Only pass true when they picked an out."),
275
+ hostJourneyId: z
276
+ .string()
277
+ .optional()
278
+ .describe("Which published journey hosts the experiment. Only needed after a 409 ambiguous_experiment_host, using one of the returned candidate ids."),
279
+ hypothesis: z
280
+ .string()
281
+ .optional()
282
+ .describe("Override the Suggested hypothesis on the draft experiment."),
283
+ beliefId: z
284
+ .string()
285
+ .optional()
286
+ .describe("Link the draft experiment to an existing belief."),
287
+ confidence: z
288
+ .number()
289
+ .optional()
290
+ .describe("Override the prefilled confidence (percent)."),
291
+ durationDays: z
292
+ .number()
293
+ .optional()
294
+ .describe("Override the prefilled decision window in days."),
295
+ }),
296
+ handler: async (args) => {
297
+ const guard = await refuseLiveApexObject({
298
+ communicationIds: [args.communicationId],
299
+ confirmed: args.confirmLiveExperiment === true,
300
+ purpose: "publish",
301
+ });
302
+ if (guard.stop) {
303
+ return { content: [{ type: "text", text: guard.text }] };
304
+ }
305
+ const body = {};
306
+ if (args.intent)
307
+ body.intent = args.intent;
308
+ if (args.confirmLiveExperiment)
309
+ body.confirmLiveExperiment = true;
310
+ if (args.hostJourneyId)
311
+ body.hostJourneyId = args.hostJourneyId;
312
+ if (args.hypothesis)
313
+ body.hypothesis = args.hypothesis;
314
+ if (args.beliefId)
315
+ body.beliefId = args.beliefId;
316
+ if (typeof args.confidence === "number")
317
+ body.confidence = args.confidence;
318
+ if (typeof args.durationDays === "number")
319
+ body.durationDays = args.durationDays;
320
+ try {
321
+ const data = await apiPost(`/api/communications/${args.communicationId}/publish`, body);
322
+ const lines = [];
323
+ if (data.published === false) {
324
+ lines.push("Nothing to publish — no unpublished changes.");
325
+ }
326
+ else {
327
+ lines.push(`Published version ${data.version ?? "?"}.`);
328
+ }
329
+ if (data.repinned?.length) {
330
+ lines.push(`${data.repinned.length} send step(s) moved to the new version.`);
331
+ }
332
+ if (data.experiment) {
333
+ lines.push(publishedExperimentLine({
334
+ id: data.experiment.id,
335
+ status: data.experiment.status ?? "draft",
336
+ url: appUrl(`/dashboard/experiments/${data.experiment.id}`),
337
+ }));
338
+ }
339
+ else if (data.experimentNotStarted) {
340
+ lines.push(notStartedExplanation(data.experimentNotStarted));
341
+ }
342
+ return { content: [{ type: "text", text: lines.join("\n") }] };
343
+ }
344
+ catch (err) {
345
+ const guidance = controlGateGuidanceFor(err, {
346
+ toolName: "publish_communication",
347
+ communicationId: args.communicationId,
348
+ });
349
+ return {
350
+ content: [
351
+ {
352
+ type: "text",
353
+ text: guidance ??
354
+ `${APEX} ${err instanceof Error ? err.message : String(err)}`,
355
+ },
356
+ ],
357
+ isError: true,
358
+ };
359
+ }
360
+ },
361
+ },
362
+ preview_communication: {
363
+ description: "See the rendered communication with your brand and Theme applied. Returns the subject, the rendered plain-text body, Title/Body/Button roles, which blocks override the theme, and a browser preview link.",
364
+ schema: z.object({
365
+ communicationId: z.string().describe("The communication ID to preview"),
366
+ }),
367
+ handler: async (args) => {
368
+ const data = await apiPost(`/api/communications/${args.communicationId}/preview`, {});
369
+ // Strip tags from subject (preview may annotate it with a slot span).
370
+ const subject = (data.subject ?? "").replace(/<[^>]+>/g, "");
371
+ const text = (data.text ?? "").trim();
372
+ const link = appUrl(`/dashboard/communications/${args.communicationId}`);
373
+ const look = data.themeLook;
374
+ const roleLines = look?.roles
375
+ ? Object.entries(look.roles).map(([role, style]) => {
376
+ const bits = [
377
+ style.type,
378
+ style.headingLevel,
379
+ style.align,
380
+ style.width,
381
+ ].filter(Boolean);
382
+ return ` ${role}: ${bits.join(" / ") || "(default)"}`;
383
+ })
384
+ : [" (no role snapshot — look is whatever is baked on the blocks)"];
385
+ const overrideLines = look?.overrides && look.overrides.length > 0
386
+ ? look.overrides.map((row) => ` ${row.label}: ${row.keys.join(", ")}`)
387
+ : [" (none)"];
388
+ return {
389
+ content: [
390
+ {
391
+ type: "text",
392
+ text: [
393
+ `Subject: ${subject || "(none)"}`,
394
+ ``,
395
+ `Rendered body:`,
396
+ text ? text : "(empty — add content via edit_communication)",
397
+ ``,
398
+ `Theme roles:`,
399
+ ...roleLines,
400
+ ``,
401
+ `Block overrides:`,
402
+ ...overrideLines,
403
+ ``,
404
+ `Open in the browser to see the full styled email: ${link}`,
405
+ ].join("\n"),
406
+ },
407
+ ],
408
+ };
409
+ },
410
+ },
411
+ add_communication_variant: {
412
+ description: "Add a content variant (Variant A, B, …) to a communication. Clones the current Control into a new column so you can run a content experiment. When the letter is a champion, this is how you author a challenger — do not edit Control. Does not change what recipients get until you publish. Theme look is inherited; do not set per-block align here.",
413
+ schema: z.object({
414
+ communicationId: z.string().describe("The communication to add a variant to"),
415
+ label: z.string().optional().describe("Optional column label (e.g. Variant A)"),
416
+ }),
417
+ handler: async (args) => {
418
+ const data = await apiPost(`/api/communications/${args.communicationId}/variants`, { label: args.label });
419
+ return {
420
+ content: [
421
+ {
422
+ type: "text",
423
+ text: `Variant added.\n\n${JSON.stringify(data, null, 2)}`,
424
+ },
425
+ ],
426
+ };
427
+ },
428
+ },
429
+ send_test_communication: {
430
+ description: "Send a test email to yourself. The email will be sent to the logged-in user's email address.",
431
+ schema: z.object({
432
+ communicationId: z.string().describe("The communication ID to test"),
433
+ }),
434
+ handler: async (args) => {
435
+ await apiPost(`/api/communications/${args.communicationId}/test`, {});
436
+ return { content: [{ type: "text", text: "Test email sent! Check your inbox." }] };
437
+ },
438
+ },
439
+ trigger_communication: {
440
+ description: "Manually send a communication to a specific recipient. Use for one-off sends or testing with specific email addresses.",
441
+ schema: z.object({
442
+ communicationId: z.string().describe("The communication ID to send"),
443
+ recipientEmail: z.string().email().describe("Email address of the recipient"),
444
+ mergeData: z.record(z.string()).optional().describe("Template variables to merge (e.g. user_name, company)"),
445
+ }),
446
+ handler: async (args) => {
447
+ // `/send` was removed. The composer test route is the one-off send:
448
+ // workspace-admin, optional recipient override, mergeData substitution.
449
+ await apiPost(`/api/communications/${args.communicationId}/test`, {
450
+ recipient: args.recipientEmail,
451
+ mergeData: args.mergeData,
452
+ });
453
+ return { content: [{ type: "text", text: `Communication sent to ${args.recipientEmail}.` }] };
454
+ },
455
+ },
456
+ create_comm_experiment: {
457
+ description: "Legacy helper. Prefer the composer loop: edit_communication or add_communication_variant, then publish_communication (compete creates a prefilled draft), then activate_experiment to start the test. Do not teach this as the default.",
458
+ schema: z.object({
459
+ communicationId: z.string().describe("The communication to experiment on"),
460
+ splitLevel: z.enum(["subject", "body", "cta", "full"]).describe("What to vary between variants"),
461
+ funnelGoal: z.string().describe("The conversion event to optimize for (e.g. 'purchase', 'signup_complete')"),
462
+ variants: z.array(z.object({
463
+ name: z.string(),
464
+ bodyOverrides: z.record(z.string()).optional(),
465
+ subjectOverride: z.string().optional(),
466
+ })).describe("Variant definitions with content overrides"),
467
+ }),
468
+ handler: async (args) => {
469
+ const data = await apiPost("/api/communications/experiments", args);
470
+ return { content: [{ type: "text", text: `Experiment created.\n\n${JSON.stringify(data, null, 2)}` }] };
471
+ },
472
+ },
473
+ wire_audience_seed: {
474
+ description: `${APEX} — Wire up the events and traits a campaign audience seed depends on. Use this when the merchant says "wire up the active customers audience" or after the AudienceActivationWizard's Screen 1 hands off to Cursor. Returns the per-event/trait code snippets, the file recommendations, and fires a test event with is_test=1 so the wizard's polling loop confirms the wiring landed end-to-end. Idempotent: calling twice does NOT double-instrument; the agent should still review existing code before adding the snippet.`,
475
+ schema: z.object({
476
+ seedId: z
477
+ .string()
478
+ .describe("Audience seed id, e.g. 'aud-seed-active-customers-90d'. Returned by the seed-campaign-defaults POST endpoint and surfaced in the wizard's URL."),
479
+ framework: z
480
+ .enum(["typescript", "snippet", "python", "ruby"])
481
+ .optional()
482
+ .describe("Code style to return. Defaults to 'typescript' (the SDK's canonical surface). Use 'snippet' for script-tag installs (Shopify, WordPress, Webflow). The wizard sets this from the detected framework."),
483
+ fireTestEvent: z
484
+ .boolean()
485
+ .optional()
486
+ .describe("When true (default), fires a test event with is_test=1 for each event the seed depends on. The wizard's poll loop sees these and advances. Set to false if the agent only wants to read the snippets without writing test data."),
487
+ }),
488
+ handler: async ({ seedId, framework = "typescript", fireTestEvent = true, }) => {
489
+ const workspaceKey = getActiveWorkspace() || (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY);
490
+ if (!workspaceKey) {
491
+ return {
492
+ content: [
493
+ {
494
+ type: "text",
495
+ text: "No active workspace. Use switch_workspace first or set APEX_WORKSPACE_KEY.",
496
+ },
497
+ ],
498
+ };
499
+ }
500
+ // Pull the seed's instrumentation requirements via the
501
+ // dashboard API. Single source of truth: the dashboard
502
+ // walks the seed's predicate the same way the
503
+ // AudienceBuilder does.
504
+ const data = await apiGet(`/api/audiences/seed-campaign-defaults/${encodeURIComponent(seedId)}/instrumentation?framework=${framework}`);
505
+ // Optionally fire test events so the wizard polling loop
506
+ // can flip into the "test received" state immediately.
507
+ // We fire one per event with is_test=1 so they don't
508
+ // pollute analytics.
509
+ const firedTests = [];
510
+ if (fireTestEvent && data.events.length > 0) {
511
+ for (const ev of data.events) {
512
+ try {
513
+ await apiPost("/api/events", {
514
+ type: "custom",
515
+ visitorId: "mcp-agent-wire-test",
516
+ data: {
517
+ eventName: ev.name,
518
+ endUserId: "mcp-agent-test-user",
519
+ is_test: 1,
520
+ source: "mcp_wire_audience_seed",
521
+ },
522
+ });
523
+ firedTests.push(ev.name);
524
+ }
525
+ catch {
526
+ // Best-effort — the agent gets the snippets even if
527
+ // test firing fails; the wizard's manual fire
528
+ // button is still available.
529
+ }
530
+ }
531
+ }
532
+ const snippetBlocks = [];
533
+ for (const ev of data.events) {
534
+ const props = ev.properties.length > 0
535
+ ? `\nProperties (predicate cares about):\n${ev.properties
536
+ .map((p) => ` - ${p.name} (${p.type}): ${p.description}`)
537
+ .join("\n")}`
538
+ : "";
539
+ snippetBlocks.push(`### Event: ${ev.name}${props}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${ev.sdkExample}\n\`\`\``);
540
+ }
541
+ for (const tr of data.traits) {
542
+ snippetBlocks.push(`### Trait: ${tr.fieldPath}\n${tr.description}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${tr.sdkExample}\n\`\`\``);
543
+ }
544
+ const summary = [
545
+ `# Wiring up "${data.seedName}"`,
546
+ ``,
547
+ `Predicate: ${data.predicateSummary}`,
548
+ ``,
549
+ `Add the following ${data.events.length} event ${data.events.length === 1 ? "call" : "calls"} and ${data.traits.length} trait ${data.traits.length === 1 ? "set" : "sets"} to the merchant's codebase. Find the right files (checkout success handler, login flow, session start) and insert the snippet, replacing placeholder properties with the merchant's actual values.`,
550
+ ``,
551
+ ...snippetBlocks,
552
+ ``,
553
+ firedTests.length > 0
554
+ ? `## Test events fired\nFired test events for: ${firedTests.join(", ")}. The wizard's polling loop will see these and advance. Confirm the merchant fires REAL events from production code paths before relying on the audience.`
555
+ : `## Test events\nNo test events fired (per fireTestEvent=false). Run the wizard's "Send a test event" button manually after wiring.`,
556
+ ``,
557
+ `Workspace: ${workspaceKey}`,
558
+ ].join("\n");
559
+ return { content: [{ type: "text", text: summary }] };
560
+ },
561
+ },
562
+ wire_audience: {
563
+ description: `${APEX} — Wire up the events and traits ANY audience depends on, not just starter audiences. Use this for bespoke / merchant-built audiences (e.g. 'New sign-ups in the last 14 days', 'Stalled sign-ups — no connector in 12h'). Walks the audience's predicate server-side, classifies each referenced event (auto-fired by the Apex snippet vs documented in Apex's event taxonomy vs custom merchant event), returns per-event recommendations, and optionally fires test events (is_test=1) for non-auto-fired events so the wizard's polling loop confirms wiring landed.`,
564
+ schema: z.object({
565
+ audienceId: z
566
+ .string()
567
+ .describe("Segment id (the workspace's saved group). Use list_segments first to find it. Bespoke segments carry generated ids; seed-cloned segments carry ids starting with `aud-seed-`."),
568
+ framework: z
569
+ .enum(["typescript", "snippet", "python", "ruby"])
570
+ .optional()
571
+ .describe("Code style to return. Defaults to 'typescript'. Use 'snippet' for script-tag installs (Shopify, WordPress, Webflow)."),
572
+ fireTestEvent: z
573
+ .boolean()
574
+ .optional()
575
+ .describe("When true (default), fires a test event with is_test=1 for each NON-AUTO-FIRED event the audience depends on. Auto-fired events (pageview, click, etc.) are skipped — they're already firing automatically and don't need a synthetic test."),
576
+ }),
577
+ handler: async ({ audienceId, framework = "typescript", fireTestEvent = true, }) => {
578
+ const workspaceKey = getActiveWorkspace() || (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY);
579
+ if (!workspaceKey) {
580
+ return {
581
+ content: [
582
+ {
583
+ type: "text",
584
+ text: "No active workspace. Use switch_workspace first or set APEX_WORKSPACE_KEY.",
585
+ },
586
+ ],
587
+ };
588
+ }
589
+ const data = await apiGet(`/api/audiences/${encodeURIComponent(audienceId)}/instrumentation?framework=${framework}`);
590
+ // Fire test events for non-auto-fired events only. Auto-fired
591
+ // events are already flowing — sending a synthetic one would
592
+ // be misleading (and it'd fail because the merchant's snippet
593
+ // is what ingests pageview, not our agent).
594
+ const wirableEvents = data.events.filter((e) => e.category !== "auto-fired-web" &&
595
+ e.category !== "auto-fired-mobile");
596
+ const firedTests = [];
597
+ const skippedAutoFired = data.events
598
+ .filter((e) => e.category === "auto-fired-web" ||
599
+ e.category === "auto-fired-mobile")
600
+ .map((e) => e.name);
601
+ if (fireTestEvent && wirableEvents.length > 0) {
602
+ for (const ev of wirableEvents) {
603
+ try {
604
+ await apiPost("/api/events", {
605
+ type: "custom",
606
+ visitorId: "mcp-agent-wire-test",
607
+ data: {
608
+ eventName: ev.name,
609
+ endUserId: "mcp-agent-test-user",
610
+ is_test: 1,
611
+ source: "mcp_wire_audience",
612
+ },
613
+ });
614
+ firedTests.push(ev.name);
615
+ }
616
+ catch {
617
+ // Best-effort.
618
+ }
619
+ }
620
+ }
621
+ const sections = [];
622
+ sections.push(`# Wiring up "${data.audienceName}"`);
623
+ if (data.audienceSummary) {
624
+ sections.push(`\n${data.audienceSummary}`);
625
+ }
626
+ if (skippedAutoFired.length > 0) {
627
+ sections.push(`\n## Already automatic (no code needed)\nThe Apex web snippet / mobile SDK fires these events automatically. Just confirm the snippet/SDK is installed:`);
628
+ for (const name of skippedAutoFired) {
629
+ sections.push(` - ${name}`);
630
+ }
631
+ }
632
+ if (wirableEvents.length > 0 || data.traits.length > 0) {
633
+ sections.push(`\n## Code to add to the merchant's codebase`);
634
+ for (const ev of wirableEvents) {
635
+ const props = ev.properties.length > 0
636
+ ? `\nProperties (audience reads these): ${ev.properties.map((p) => p.name).join(", ")}`
637
+ : "";
638
+ sections.push(`\n### Event: ${ev.name} (${ev.category})\n${ev.caption}${props}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${ev.sdkExample}\n\`\`\``);
639
+ }
640
+ for (const tr of data.traits) {
641
+ sections.push(`\n### Trait: ${tr.fieldPath}\n${tr.description}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${tr.sdkExample}\n\`\`\``);
642
+ }
643
+ }
644
+ if (firedTests.length > 0) {
645
+ sections.push(`\n## Test events fired\nFired test events (is_test=1) for: ${firedTests.join(", ")}. The wizard's polling loop will see these and advance. Confirm the merchant fires REAL events from production code paths before relying on the audience.`);
646
+ }
647
+ else if (wirableEvents.length === 0) {
648
+ sections.push(`\n## Test events\nNothing to fire — every event is auto-fired by Apex's snippet/SDK.`);
649
+ }
650
+ else {
651
+ sections.push(`\n## Test events\nNo test events fired (per fireTestEvent=false). Run the wizard's "Send a test event" button manually after wiring.`);
652
+ }
653
+ sections.push(`\nWorkspace: ${workspaceKey}`);
654
+ return { content: [{ type: "text", text: sections.join("\n") }] };
655
+ },
656
+ },
657
+ audit_audience_readiness: {
658
+ description: `${APEX} — Check whether an audience is wired up and matching users. Use this to verify your wire_audience_seed work succeeded, or to diagnose why a campaign is showing zero matches. Returns the audience predicate, current matching count, recency buckets, and which referenced events have arrived from the workspace.`,
659
+ schema: z.object({
660
+ audienceId: z
661
+ .string()
662
+ .describe("Segment id (the workspace's saved group, not the seed). Use list_segments first to find it."),
663
+ }),
664
+ handler: async ({ audienceId }) => {
665
+ const workspaceKey = getActiveWorkspace() || (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY);
666
+ if (!workspaceKey) {
667
+ return {
668
+ content: [
669
+ {
670
+ type: "text",
671
+ text: "No active workspace. Use switch_workspace first or set APEX_WORKSPACE_KEY.",
672
+ },
673
+ ],
674
+ };
675
+ }
676
+ const [audience, preview] = await Promise.all([
677
+ apiGet(`/api/audiences/${encodeURIComponent(audienceId)}`),
678
+ apiGet(`/api/audiences/${encodeURIComponent(audienceId)}/preview`).catch(() => null),
679
+ ]);
680
+ const matchingLine = preview
681
+ ? `${preview.matchingCount.toLocaleString()} user${preview.matchingCount === 1 ? "" : "s"} currently match`
682
+ : "Couldn't compute live preview (audience may still resolve correctly at send time)";
683
+ const recencyLines = preview
684
+ ? [
685
+ ` - Last 24h: ${preview.recencyBuckets.last_24h}`,
686
+ ` - Last 7d: ${preview.recencyBuckets.last_7d}`,
687
+ ` - Last 30d: ${preview.recencyBuckets.last_30d}`,
688
+ ` - Older: ${preview.recencyBuckets.older}`,
689
+ ].join("\n")
690
+ : " (no recency data)";
691
+ const summary = [
692
+ `# Audience readiness: ${audience.name}`,
693
+ audience.description ? `\n${audience.description}` : "",
694
+ `\n## Match status\n${matchingLine}`,
695
+ `\n## Recency buckets\n${recencyLines}`,
696
+ `\n## Predicate\n\`\`\`json\n${JSON.stringify(audience.predicate, null, 2)}\n\`\`\``,
697
+ preview && preview.matchingCount === 0
698
+ ? `\n## Diagnosis\nZero matches. Most likely causes:\n 1. Events the predicate references aren't firing yet from the merchant's codebase. Run \`wire_audience_seed\` if this is a starter audience, or check the predicate's event names against your codebase.\n 2. The predicate's window (\`last_n_days\`, \`ever\`) is too narrow.\n 3. End-user identity isn't being stitched. Each \`apex.track()\` call must include \`endUserId\` in its data payload, or be preceded by an \`identify()\` call.\n`
699
+ : "",
700
+ `\nWorkspace: ${workspaceKey}`,
701
+ ].join("\n");
702
+ return { content: [{ type: "text", text: summary }] };
703
+ },
704
+ },
705
+ // ─── Journey Exit Semantics — catch-up tools ────────────────────────────
706
+ //
707
+ // Wraps the journey-management + exit-rules + cart-state API
708
+ // surfaces shipped in `journey_exit_semantics_0b4fe496.plan.md`.
709
+ // Agent-friendly arg names (snake_case) are mapped to the REST
710
+ // routes inside each handler.,
711
+ create_journey: {
712
+ description: `${APEX} — Create a blank draft journey (an unconfigured event trigger wired to an exit). Then set_journey_trigger and add_journey_step to build it, and publish_journey to launch. For e-commerce flows like cart recovery, prefer create_journey_from_template. audience_bucket defaults to production (live customers). Same email on TestFlight and the store is two people — do not create a staging copy of the journey.`,
713
+ schema: z.object({
714
+ name: z.string().optional().describe("Journey name (default 'Untitled journey')."),
715
+ audience_bucket: z
716
+ .enum(["production", "beta", "dev"])
717
+ .optional()
718
+ .describe("Who this journey enrolls. Default production. Never All or Unclassified."),
719
+ }),
720
+ handler: async ({ name, audience_bucket }) => {
721
+ try {
722
+ const j = await apiPost("/api/journeys/blank", { name });
723
+ if (audience_bucket && audience_bucket !== "production") {
724
+ await apiPatch(`/api/journeys/${j.id}`, { audienceBucket: audience_bucket });
725
+ }
726
+ if (!tenantOk(j.workspaceKey))
727
+ return tenantMismatch();
728
+ return { content: [{ type: "text", text: `${APEX} Created draft journey "${j.name}" (id: ${j.id}). Open it in the browser: ${journeyLink(j.id)}\nNext: set_journey_trigger, then add_journey_step.` }] };
729
+ }
730
+ catch (err) {
731
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
732
+ }
733
+ },
734
+ },
735
+ create_journey_from_template: {
736
+ description: `${APEX} — Instantiate a starter journey template (e.g. Abandoned Cart Recovery) into a fresh draft. Get the templateId from get_wiring_status (Ready-to-run templates list their id). After instantiating, review the steps, then publish_journey.`,
737
+ schema: z.object({ templateId: z.string().describe("Template id from get_wiring_status (e.g. 'mobile-cart-recovery').") }),
738
+ handler: async ({ templateId }) => {
739
+ try {
740
+ const j = await apiPost("/api/journeys/from-template", { templateId });
741
+ if (!tenantOk(j.workspaceKey))
742
+ return tenantMismatch();
743
+ return { content: [{ type: "text", text: `${APEX} Instantiated "${j.name}" (id: ${j.id}) from template ${templateId}. Open it in the browser: ${journeyLink(j.id)}\nReview its steps with get_journey, then publish_journey.` }] };
744
+ }
745
+ catch (err) {
746
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
747
+ }
748
+ },
749
+ },
750
+ list_trigger_contracts: {
751
+ description: `${APEX} — List the workspace's trigger contracts (the event bindings you pass to set_journey_trigger / add_journey_exit). Returns each contract's id + the event it fires on. Call this before set_journey_trigger so you use a real id instead of guessing.`,
752
+ schema: z.object({}),
753
+ handler: async () => {
754
+ const data = await apiGet("/api/journeys/trigger-contracts");
755
+ const contracts = data.contracts ?? [];
756
+ if (contracts.length === 0) {
757
+ return { content: [{ type: "text", text: `${APEX} No trigger contracts registered yet. Creating a blank journey seeds the starter set.` }] };
758
+ }
759
+ const lines = contracts.map((c) => ` • ${c.id} → ${c.eventName ?? "?"}`);
760
+ return {
761
+ content: [
762
+ {
763
+ type: "text",
764
+ text: [`${APEX} Trigger contracts (${contracts.length})`, ...lines, ``, `Pass one of these ids as triggerContractId to set_journey_trigger.`].join("\n"),
765
+ },
766
+ ],
767
+ };
768
+ },
769
+ },
770
+ set_journey_trigger: {
771
+ description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass event_name (the Apex Spec event, e.g. user_signed_up). A trig-* contract id is storage-only — prefer the Spec name.`,
772
+ schema: z.object({
773
+ journeyId: z.string(),
774
+ eventName: z.string().optional().describe("Apex Spec event name that enrolls subjects (e.g. user_signed_up). Preferred."),
775
+ triggerContractId: z.string().optional().describe("Storage id from list_trigger_contracts. Prefer event_name."),
776
+ }),
777
+ handler: async ({ journeyId, eventName, triggerContractId, }) => {
778
+ try {
779
+ const cdata = await apiGet("/api/journeys/trigger-contracts");
780
+ const contracts = cdata.contracts ?? [];
781
+ let resolvedId = triggerContractId;
782
+ if (eventName) {
783
+ const match = contracts.find((c) => c.eventName === eventName);
784
+ if (match) {
785
+ resolvedId = match.id;
786
+ }
787
+ else {
788
+ const created = await apiPost("/api/journey-triggers", {
789
+ eventName,
790
+ fieldMappings: { end_user_id: "endUserId" },
791
+ recipientInitiated: true,
792
+ });
793
+ resolvedId = created.id;
794
+ }
795
+ }
796
+ if (!resolvedId) {
797
+ return {
798
+ content: [{ type: "text", text: `${APEX} Pass event_name (preferred) or triggerContractId.` }],
799
+ isError: true,
800
+ };
801
+ }
802
+ if (!eventName &&
803
+ contracts.length > 0 &&
804
+ !contracts.some((c) => c.id === resolvedId)) {
805
+ const opts = contracts
806
+ .map((c) => `${c.id}${c.eventName ? ` (${c.eventName})` : ""}`)
807
+ .join(", ");
808
+ return {
809
+ content: [
810
+ {
811
+ type: "text",
812
+ text: `${APEX} Unknown triggerContractId "${resolvedId}". Valid options: ${opts}`,
813
+ },
814
+ ],
815
+ isError: true,
816
+ };
817
+ }
818
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
819
+ const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId: resolvedId } } : s);
820
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
821
+ if (!tenantOk(updated.workspaceKey))
822
+ return tenantMismatch();
823
+ const label = eventName ? `${eventName} (${resolvedId})` : resolvedId;
824
+ return { content: [{ type: "text", text: `${APEX} Trigger set to ${label} on journey ${journeyId}. View it: ${journeyLink(journeyId)}` }] };
825
+ }
826
+ catch (err) {
827
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
828
+ }
829
+ },
830
+ },
831
+ add_journey_step: {
832
+ description: `${APEX} — Append a Wait or Send to a draft Adaptive Journey. Prefer compose_adoption_milestone for a Milestone (one Wait until they finish, letters already on the arms). Wait mode duration (ISO, e.g. P1D) or until_event (trigger_contract_id + deadline_iso). Send arm event|deadline puts the letter on that Wait arm. Do not add_journey_step send on a compose-built path — use set_journey_send to point the existing Send.`,
833
+ schema: z.object({
834
+ journeyId: z.string(),
835
+ kind: z.enum(["wait", "send"]),
836
+ mode: z.enum(["duration", "until_event"]).optional().describe("wait only. Default duration."),
837
+ durationIso: z.string().optional().describe("wait duration: ISO-8601, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
838
+ triggerContractId: z.string().optional().describe("wait until_event: the done-event trigger contract."),
839
+ deadlineIso: z.string().optional().describe("wait until_event: hours or days as ISO-8601, e.g. 'PT1H', 'P3D'."),
840
+ commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
841
+ commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
842
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email']). Use the SAME tokens as create_communication (email, in_app_push, mobile_push) so a comm's channels map cleanly onto the send step; web_push is send-only."),
843
+ arm: z.enum(["event", "deadline"]).optional().describe("send only: put this Send on the until_event Wait's event or deadline arm."),
844
+ }),
845
+ handler: async (args) => {
846
+ try {
847
+ const guard = await refuseLiveApexObject({
848
+ journeyIds: [args.journeyId],
849
+ purpose: "draft",
850
+ });
851
+ if (guard.stop) {
852
+ return { content: [{ type: "text", text: guard.text }] };
853
+ }
854
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
855
+ const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
856
+ const applied = applyAddJourneyStep(j.steps ?? [], args, newId);
857
+ if (!applied.ok) {
858
+ return { content: [{ type: "text", text: `${APEX} ${applied.error}` }], isError: true };
859
+ }
860
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps: applied.steps });
861
+ if (!tenantOk(updated.workspaceKey))
862
+ return tenantMismatch();
863
+ return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step ${applied.stepId} to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
864
+ }
865
+ catch (err) {
866
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
867
+ }
868
+ },
869
+ },
870
+ set_journey_send: {
871
+ description: `${APEX} — Point a draft journey's send step at a communication. Use after create_communication + edit_communication so the letter on the journey is yours, not the shared starter template. If the journey has one send, that send is updated. If it has several, pass step_id.`,
872
+ schema: z.object({
873
+ journeyId: z.string(),
874
+ commId: z.string().describe("The communication id from create_communication."),
875
+ stepId: z.string().optional().describe("Required when the journey has more than one send."),
876
+ commVersion: z.number().optional().describe("Pinned comm version. Default 1."),
877
+ }),
878
+ handler: async (args) => {
879
+ try {
880
+ const guard = await refuseLiveApexObject({
881
+ journeyIds: [args.journeyId],
882
+ communicationIds: [args.commId],
883
+ purpose: "draft",
884
+ });
885
+ if (guard.stop) {
886
+ return { content: [{ type: "text", text: guard.text }] };
887
+ }
888
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
889
+ const steps = [...(j.steps ?? [])];
890
+ const sends = steps.filter((s) => s.type === "send");
891
+ const target = args.stepId
892
+ ? sends.find((s) => s.id === args.stepId)
893
+ : sends.length === 1
894
+ ? sends[0]
895
+ : undefined;
896
+ if (!target) {
897
+ return {
898
+ content: [{
899
+ type: "text",
900
+ text: sends.length === 0
901
+ ? `${APEX} This journey has no send step. Use add_journey_step kind=send.`
902
+ : `${APEX} This journey has ${sends.length} send steps. Pass step_id (${sends.map((s) => s.id).join(", ")}).`,
903
+ }],
904
+ isError: true,
905
+ };
906
+ }
907
+ target.commId = args.commId;
908
+ target.commVersion = args.commVersion ?? 1;
909
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
910
+ if (!tenantOk(updated.workspaceKey))
911
+ return tenantMismatch();
912
+ return {
913
+ content: [{
914
+ type: "text",
915
+ text: `${APEX} Send step ${String(target.id)} now uses ${args.commId}. View it: ${journeyLink(args.journeyId)}`,
916
+ }],
917
+ };
918
+ }
919
+ catch (err) {
920
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
921
+ }
922
+ },
923
+ },
924
+ add_journey_branch: {
925
+ description: `${APEX} — Insert a CONDITIONAL content branch before the exit: evaluate one condition and send a different communication to each side. Use this for true content branching (e.g. "cart value > $100 → VIP email, else standard email"). For the simpler "skip the next send if they already converted" case, use add_journey_exit instead. The condition is an attribute test (field_path + operator + value) or an event test (event_fired / event_not_fired within a window).`,
926
+ schema: z.object({
927
+ journeyId: z.string(),
928
+ condition: z.object({
929
+ kind: z.enum(["attribute", "event_fired", "event_not_fired"]),
930
+ field_path: z.string().optional().describe("attribute only: dotted path, e.g. 'cart.valueCents' or 'plan'."),
931
+ operator: z
932
+ .enum(["equals", "not_equals", "greater_than", "less_than", "in", "exists", "not_exists"])
933
+ .optional()
934
+ .describe("attribute only."),
935
+ value: z
936
+ .union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
937
+ .optional()
938
+ .describe("attribute only: the comparison value (omit for exists/not_exists)."),
939
+ event_name: z.string().optional().describe("event only: the canonical event, e.g. 'purchase'."),
940
+ window_days: z.number().optional().describe("event only: look-back window in days (omit = ever)."),
941
+ }),
942
+ whenTrueCommId: z.string().describe("Communication to send when the condition matches."),
943
+ whenFalseCommId: z.string().optional().describe("Communication to send when it doesn't match (omit to just continue to exit)."),
944
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("Channels for both send arms (default ['email'])."),
945
+ }),
946
+ handler: async (args) => {
947
+ try {
948
+ const guard = await refuseLiveApexObject({
949
+ journeyIds: [args.journeyId],
950
+ communicationIds: [args.whenTrueCommId, args.whenFalseCommId].filter((id) => Boolean(id)),
951
+ purpose: "draft",
952
+ });
953
+ if (guard.stop) {
954
+ return { content: [{ type: "text", text: guard.text }] };
955
+ }
956
+ const c = args.condition;
957
+ // Build the predicate leaf (AudiencePredicate is a bare leaf here).
958
+ let predicate;
959
+ if (c.kind === "attribute") {
960
+ if (!c.field_path || !c.operator) {
961
+ return { content: [{ type: "text", text: `${APEX} An attribute condition needs field_path and operator.` }], isError: true };
962
+ }
963
+ predicate = {
964
+ kind: "attribute",
965
+ fieldPath: c.field_path,
966
+ operator: c.operator,
967
+ ...(c.value !== undefined ? { value: c.value } : {}),
968
+ };
969
+ }
970
+ else {
971
+ if (!c.event_name) {
972
+ return { content: [{ type: "text", text: `${APEX} An event condition needs event_name.` }], isError: true };
973
+ }
974
+ predicate = {
975
+ kind: c.kind,
976
+ eventName: c.event_name,
977
+ window: c.window_days ? { type: "last_n_days", days: c.window_days } : { type: "ever" },
978
+ };
979
+ }
980
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
981
+ const steps = [...(j.steps ?? [])];
982
+ const exit = steps.find((s) => s.type === "exit");
983
+ if (!exit) {
984
+ return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert a branch.` }], isError: true };
985
+ }
986
+ const exitId = exit.id;
987
+ const channels = args.channels ?? ["email"];
988
+ const mkId = () => `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
989
+ const trueId = mkId();
990
+ const trueStep = { id: trueId, type: "send", label: "Send (match)", commId: args.whenTrueCommId, commVersion: 1, channels, next: exitId };
991
+ const newSteps = [trueStep];
992
+ let defaultNext = exitId;
993
+ if (args.whenFalseCommId) {
994
+ const falseId = mkId();
995
+ newSteps.push({ id: falseId, type: "send", label: "Send (no match)", commId: args.whenFalseCommId, commVersion: 1, channels, next: exitId });
996
+ defaultNext = falseId;
997
+ }
998
+ const branchId = mkId();
999
+ const branchStep = {
1000
+ id: branchId,
1001
+ type: "branch",
1002
+ label: "Branch",
1003
+ mode: "condition",
1004
+ conditions: [{ predicate, next: trueId }],
1005
+ defaultNext,
1006
+ };
1007
+ // Insert the branch before the exit: rewire whatever currently points
1008
+ // to the exit so the branch sits in the linear flow. (newSteps aren't in
1009
+ // `steps` yet, so this only matches the existing pre-exit step.)
1010
+ const pre = steps.find((s) => s.next === exitId);
1011
+ if (pre)
1012
+ pre.next = branchId;
1013
+ steps.push(branchStep, ...newSteps);
1014
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
1015
+ if (!tenantOk(updated.workspaceKey))
1016
+ return tenantMismatch();
1017
+ return { content: [{ type: "text", text: `${APEX} Added a conditional branch to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
1018
+ }
1019
+ catch (err) {
1020
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1021
+ }
1022
+ },
1023
+ },
1024
+ publish_journey: {
1025
+ description: `${APEX} — Publish a draft journey so it runs on live trigger events. SAFETY: defaults to a dry-run (validation only) — pass confirmLive:true to actually publish to real customers. Email sends require a verified sender domain or publish is blocked. If this path is already live or a test is measuring it, stop and ask. Do not pass confirmTreatmentChange unless they picked an out.`,
1026
+ schema: z.object({
1027
+ journeyId: z.string(),
1028
+ confirmLive: z.boolean().optional().describe("Set true to publish for real. Omitted/false = dry-run validation only."),
1029
+ confirmTreatmentChange: z
1030
+ .boolean()
1031
+ .optional()
1032
+ .describe("Publish anyway when this path is live or a test is measuring it. Only pass true after they picked an out."),
1033
+ }),
1034
+ handler: async ({ journeyId, confirmLive, confirmTreatmentChange, }) => {
1035
+ const path = `/api/journeys/${encodeURIComponent(journeyId)}/publish`;
1036
+ // Non-blocking coverage warnings ("we couldn't verify these until the
1037
+ // journey fires") — surfaced so an agent can preview before publishing.
1038
+ const warnText = (warnings) => {
1039
+ const list = Array.isArray(warnings) ? warnings.filter((w) => w?.reason) : [];
1040
+ if (list.length === 0)
1041
+ return "";
1042
+ return (`\n\n${list.length} thing${list.length === 1 ? "" : "s"} we couldn't verify until it fires (preview with example data to check):\n` +
1043
+ list.map((w) => ` • ${w.reason}`).join("\n"));
1044
+ };
1045
+ if (!confirmLive) {
1046
+ try {
1047
+ const dr = await apiPost(`${path}?dryRun=true`, {});
1048
+ return { content: [{ type: "text", text: `${APEX} Dry-run passed — journey ${journeyId} is valid and ready.${warnText(dr?.warnings)}\nRe-call with confirmLive:true to publish it to live customers.` }] };
1049
+ }
1050
+ catch (err) {
1051
+ // A variable-coverage block arrives here with a plain message like
1052
+ // "This send uses {{first_name}}, but the trigger doesn't provide it."
1053
+ return { content: [{ type: "text", text: `${APEX} Dry-run found a problem before publishing: ${errMsg(err)}\nIf a variable won't resolve, map it to a field the trigger provides or remove it, then preview with example data.` }], isError: true };
1054
+ }
1055
+ }
1056
+ const guard = await refuseLiveApexObject({
1057
+ journeyIds: [journeyId],
1058
+ includePublishedJourneys: true,
1059
+ confirmed: confirmTreatmentChange === true,
1060
+ purpose: "publish",
1061
+ });
1062
+ if (guard.stop) {
1063
+ return { content: [{ type: "text", text: guard.text }] };
1064
+ }
1065
+ try {
1066
+ const res = await apiPost(path, {});
1067
+ if (!tenantOk(res.workspaceKey))
1068
+ return tenantMismatch();
1069
+ return { content: [{ type: "text", text: `${APEX} Published journey ${journeyId} — it is now live on trigger events.${warnText(res?.warnings)} View it: ${journeyLink(journeyId)}` }] };
1070
+ }
1071
+ catch (err) {
1072
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1073
+ }
1074
+ },
1075
+ },
1076
+ create_communication: {
1077
+ description: `${APEX} — Create a blank custom communication (draft, no send path) you can reference from a journey send step. Distinct from generate_communications, which generates pre-written messages from catalog templates; use this when you need a net-new message (e.g. a cart-recovery email not in the catalog). audience_bucket defaults to production.`,
1078
+ schema: z.object({
1079
+ title: z.string().optional(),
1080
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push"])).optional(),
1081
+ audience_bucket: z
1082
+ .enum(["production", "beta", "dev"])
1083
+ .optional()
1084
+ .describe("Who this send is for. Default production. Never All or Unclassified."),
1085
+ }),
1086
+ handler: async ({ title, channels, audience_bucket }) => {
1087
+ try {
1088
+ const comm = await apiPost("/api/communications/blank", { title, channels });
1089
+ if (audience_bucket && audience_bucket !== "production") {
1090
+ await apiPatch(`/api/communications/${comm.id}`, { audienceBucket: audience_bucket });
1091
+ }
1092
+ if (!tenantOk(comm.workspaceKey))
1093
+ return tenantMismatch();
1094
+ return { content: [{ type: "text", text: `${APEX} Created draft communication "${comm.title}" (id: ${comm.id}). Open it in the browser: ${appUrl(`/dashboard/communications/${comm.id}`)}\nEdit it (or edit_communication), then reference it from a journey send step.` }] };
1095
+ }
1096
+ catch (err) {
1097
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1098
+ }
1099
+ },
1100
+ },
1101
+ list_journeys: {
1102
+ description: `${APEX} — List every adaptive journey in the active workspace. Returns id, name, status (draft/published/paused/archived), and journey type (lifecycle/campaign). Foundation for exit-rule audit loops + journey CRUD agent flows.`,
1103
+ schema: z.object({}),
1104
+ handler: async () => {
1105
+ const journeys = await apiGet("/api/journeys");
1106
+ return {
1107
+ content: [
1108
+ {
1109
+ type: "text",
1110
+ text: JSON.stringify(journeys.map((j) => ({
1111
+ id: j.id,
1112
+ name: j.name,
1113
+ status: j.status,
1114
+ journey_type: j.journeyType ?? null,
1115
+ version: j.version,
1116
+ exit_rule_count: (j.exitEvents ?? []).length,
1117
+ })), null, 2),
1118
+ },
1119
+ ],
1120
+ };
1121
+ },
1122
+ },
1123
+ get_journey: {
1124
+ description: `${APEX} — Read the full configuration for a single journey, including steps, goal event, attribution window, and exit rules. Use after list_journeys to inspect a journey's wiring before recommending edits.`,
1125
+ schema: z.object({
1126
+ journeyId: z.string().describe("Journey id returned from list_journeys."),
1127
+ }),
1128
+ handler: async ({ journeyId }) => {
1129
+ const journey = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
1130
+ return {
1131
+ content: [
1132
+ {
1133
+ type: "text",
1134
+ text: JSON.stringify(journey, null, 2),
1135
+ },
1136
+ ],
1137
+ };
1138
+ },
1139
+ },
1140
+ // ─── Accounts (B2B-000 rollup + B2B-001 groups) ───────────────────────,
1141
+ list_journey_exits: {
1142
+ description: `${APEX} — Just the exit-rules block of one journey. Cheaper than get_journey for audit loops that only need to know "does this journey suppress purchase / unsubscribe?".`,
1143
+ schema: z.object({
1144
+ journeyId: z.string(),
1145
+ }),
1146
+ handler: async ({ journeyId }) => {
1147
+ const body = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`);
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: "text",
1152
+ text: JSON.stringify(body.data ?? [], null, 2),
1153
+ },
1154
+ ],
1155
+ };
1156
+ },
1157
+ },
1158
+ add_journey_exit: {
1159
+ description: `${APEX} — Append a new exit rule to a journey's draft. Rule fires when the chosen trigger contract's event lands on a contact with an in-flight execution — Apex stops the execution and skips any pending sends. Re-publish required for the rule to take effect on live runs.`,
1160
+ schema: z.object({
1161
+ journeyId: z.string(),
1162
+ triggerContractId: z
1163
+ .string()
1164
+ .describe("Trigger-contract id, e.g. trig-in-app-purchase"),
1165
+ label: z.string().optional(),
1166
+ enabled: z.boolean().optional().default(true),
1167
+ }),
1168
+ handler: async ({ journeyId, triggerContractId, label, enabled, }) => {
1169
+ const body = await apiPost(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`, {
1170
+ triggerContractId,
1171
+ ...(label !== undefined && { label }),
1172
+ enabled: enabled !== false,
1173
+ });
1174
+ return {
1175
+ content: [
1176
+ {
1177
+ type: "text",
1178
+ text: `Added exit rule ${body.data.id} → ${body.data.triggerContractId}. Publish the journey for it to apply to new executions.`,
1179
+ },
1180
+ ],
1181
+ };
1182
+ },
1183
+ },
1184
+ remove_journey_exit: {
1185
+ description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
1186
+ schema: z.object({
1187
+ journeyId: z.string(),
1188
+ ruleId: z.string(),
1189
+ }),
1190
+ handler: async ({ journeyId, ruleId, }) => {
1191
+ await apiDelete(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules/${encodeURIComponent(ruleId)}`);
1192
+ return {
1193
+ content: [
1194
+ {
1195
+ type: "text",
1196
+ text: `Removed exit rule ${ruleId} from journey ${journeyId}.`,
1197
+ },
1198
+ ],
1199
+ };
1200
+ },
1201
+ },
1202
+ get_contact_cart: {
1203
+ description: `${APEX} — Read the rolled-up cart state for a contact (Phase 2 of Journey Exit Semantics). Returns lines, itemCount, valueCents, and last-updated timestamp. Useful for "is this lead worth a follow-up?" agent loops.`,
1204
+ schema: z.object({
1205
+ contact_id: z.string(),
1206
+ }),
1207
+ handler: async ({ contact_id }) => {
1208
+ const contact = await apiGet(`/api/contacts/${encodeURIComponent(contact_id)}`);
1209
+ return {
1210
+ content: [
1211
+ {
1212
+ type: "text",
1213
+ text: JSON.stringify({
1214
+ contact_id: contact.id,
1215
+ email: contact.email ?? null,
1216
+ cart: contact.cart ?? null,
1217
+ }, null, 2),
1218
+ },
1219
+ ],
1220
+ };
1221
+ },
1222
+ },
1223
+ audit_journey_exits: {
1224
+ description: `${APEX} — Audit every published journey in the workspace for exit-rule hygiene. Flags marketing/lifecycle journeys (goalEventName=in_app_purchase) that don't have a purchase exit rule — the most common misconfiguration that produces "received the recovery push AFTER converting" complaints.`,
1225
+ schema: z.object({}),
1226
+ handler: async () => {
1227
+ const journeys = await apiGet("/api/journeys");
1228
+ const findings = [];
1229
+ for (const j of journeys) {
1230
+ if (j.status !== "published" && j.status !== "paused")
1231
+ continue;
1232
+ const isMarketing = j.journeyType === "campaign" || j.journeyType === "lifecycle";
1233
+ if (!isMarketing)
1234
+ continue;
1235
+ const goalLooksLikePurchase = j.goalEventName === "in_app_purchase" ||
1236
+ j.goalEventName === "purchase";
1237
+ if (!goalLooksLikePurchase)
1238
+ continue;
1239
+ const hasPurchaseExit = (j.exitEvents ?? []).some((r) => r.enabled !== false &&
1240
+ (r.triggerContractId.includes("purchase") ||
1241
+ (r.label ?? "").toLowerCase().includes("purchase")));
1242
+ if (!hasPurchaseExit) {
1243
+ findings.push({
1244
+ journey_id: j.id,
1245
+ name: j.name,
1246
+ recommendation: "Marketing journey with purchase goal but no purchase exit rule. Users who convert mid-journey will still receive later sends.",
1247
+ });
1248
+ }
1249
+ }
1250
+ const text = findings.length === 0
1251
+ ? "✓ All published marketing journeys with a purchase goal have a purchase exit rule wired."
1252
+ : `# Exit-rule audit — ${findings.length} journey${findings.length === 1 ? "" : "s"} need attention\n\n${findings.map((f) => `- **${f.name}** (${f.journey_id}): ${f.recommendation}`).join("\n")}\n\nFix with: \`add_journey_exit\` for each journey using a purchase trigger contract.`;
1253
+ return { content: [{ type: "text", text }] };
1254
+ },
1255
+ },
1256
+ list_segments: {
1257
+ description: `${APEX} — List the workspace's segments (saved groups of customers over your own events + traits).`,
1258
+ schema: z.object({
1259
+ scope: z.enum(["workspace", "org"]).optional().describe("Defaults to workspace"),
1260
+ }),
1261
+ handler: async (args) => {
1262
+ const q = args.scope ? `?scope=${args.scope}` : "";
1263
+ const data = await apiGet(`/api/segments${q}`);
1264
+ return {
1265
+ content: [{ type: "text", text: JSON.stringify(data ?? [], null, 2) }],
1266
+ };
1267
+ },
1268
+ },
1269
+ get_segment: {
1270
+ description: `${APEX} — Fetch one segment by id.`,
1271
+ schema: z.object({
1272
+ segment_id: z.string().describe("The segment id"),
1273
+ scope: z.enum(["workspace", "org"]).optional(),
1274
+ }),
1275
+ handler: async (args) => {
1276
+ const q = args.scope ? `?scope=${args.scope}` : "";
1277
+ const data = await apiGet(`/api/segments/${encodeURIComponent(args.segment_id)}${q}`);
1278
+ return { content: [{ type: "text", text: JSON.stringify(data ?? null, null, 2) }] };
1279
+ },
1280
+ },
1281
+ create_segment: {
1282
+ description: `${APEX} — Create a segment from friendly "conditions" (compiled server-side) OR a raw predicate. Conditions grammar: match all|any of groups; each group match all|any of conditions; each condition is an event ({verb:"has"|"hasnt", event, window_days?}) or a trait ({attribute, op:"is"|"is_not"|"exists", value?}). This is the same grammar the dashboard chip builder speaks.`,
1283
+ schema: z.object({
1284
+ name: z.string().describe("Segment name"),
1285
+ description: z.string().optional(),
1286
+ scope: z.enum(["workspace", "org"]).optional().describe("Defaults to workspace"),
1287
+ conditions: z
1288
+ .object({
1289
+ match: z.enum(["all", "any"]),
1290
+ groups: z.array(z.object({
1291
+ match: z.enum(["all", "any"]),
1292
+ conditions: z.array(z.object({
1293
+ verb: z.enum(["has", "hasnt"]).optional(),
1294
+ event: z.string().optional(),
1295
+ window_days: z.number().optional(),
1296
+ attribute: z.string().optional(),
1297
+ op: z.enum(["is", "is_not", "exists"]).optional(),
1298
+ value: z.union([z.string(), z.number(), z.boolean()]).optional(),
1299
+ })),
1300
+ })),
1301
+ })
1302
+ .optional()
1303
+ .describe("Friendly conditions (preferred). Sub-keys are snake_case."),
1304
+ predicate: z.record(z.string(), z.unknown()).optional().describe("Raw SegmentPredicate escape hatch"),
1305
+ }),
1306
+ handler: async (args) => {
1307
+ const body = {
1308
+ scope: args.scope ?? "workspace",
1309
+ name: args.name,
1310
+ };
1311
+ if (args.description)
1312
+ body.description = args.description;
1313
+ if (args.predicate)
1314
+ body.predicate = args.predicate;
1315
+ else if (args.conditions)
1316
+ body.conditions = args.conditions;
1317
+ const data = await apiPost("/api/segments", body);
1318
+ return {
1319
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
1320
+ };
1321
+ },
1322
+ },
1323
+ };
1324
+ //# sourceMappingURL=letters.js.map