@apex-inc/mcp-server 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources.js +22 -20
- package/dist/resources.js.map +1 -1
- package/dist/tools.d.ts +283 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +357 -16
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +24 -0
package/dist/tools.js
CHANGED
|
@@ -51,6 +51,36 @@ function titleCaseEvent(event) {
|
|
|
51
51
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
52
52
|
.join(" ");
|
|
53
53
|
}
|
|
54
|
+
function errMsg(err) {
|
|
55
|
+
return err instanceof Error ? err.message : String(err);
|
|
56
|
+
}
|
|
57
|
+
/** Build a dashboard deep link on the same host the MCP talks to (app origin). */
|
|
58
|
+
function appUrl(path) {
|
|
59
|
+
const base = process.env.APEX_URL || process.env.APEX_API_URL || "http://localhost:3001";
|
|
60
|
+
return `${base.replace(/\/$/, "")}${path}`;
|
|
61
|
+
}
|
|
62
|
+
function journeyLink(id) {
|
|
63
|
+
return appUrl(`/dashboard/communications/journeys/${id}`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Tenant echo (council CISO): never report a mutation as successful for a record
|
|
67
|
+
* outside the active workspace (guards a leaked/cross-workspace key).
|
|
68
|
+
*/
|
|
69
|
+
function tenantOk(workspaceKey) {
|
|
70
|
+
const active = getActiveWorkspace();
|
|
71
|
+
return !active || !workspaceKey || workspaceKey === active;
|
|
72
|
+
}
|
|
73
|
+
function tenantMismatch() {
|
|
74
|
+
return {
|
|
75
|
+
content: [
|
|
76
|
+
{
|
|
77
|
+
type: "text",
|
|
78
|
+
text: `${APEX} Refusing to confirm: the affected record belongs to a different workspace.`,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
isError: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
54
84
|
/**
|
|
55
85
|
* Shared schema + handler for `switch_workspace` (canonical) and its
|
|
56
86
|
* deprecated alias `switch_project`. Both register against the same handler
|
|
@@ -302,7 +332,9 @@ export const toolDefinitions = {
|
|
|
302
332
|
primaryMetricEvent: z.string().optional().describe("Canonical event name the experiment optimizes, e.g. 'add_to_cart', 'checkout_completed', 'form_submit'. Validated against the workspace event spec. Prefer an OUTCOME event (purchase/signup) over a proxy (email_open) — use list_metrics to see which are DETECTED with volume."),
|
|
303
333
|
primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional().describe("How the goal is measured. 'rate' (default) = did it happen. 'revenue'/'duration' = a continuous value summed per subject (set primaryMetricValueProperty). Use 'revenue' for Stripe/purchase-value goals."),
|
|
304
334
|
primaryMetricValueProperty: z.string().optional().describe("For revenue/duration goals: the event property to sum, e.g. 'value'. Defaults to 'value'."),
|
|
305
|
-
guardrailEvent: z.string().optional().describe("Canonical event for
|
|
335
|
+
guardrailEvent: z.string().optional().describe("Canonical protective event for an EXTRA guardrail that must not regress (e.g. 'purchase_refunded', 'app_uninstall', 'js_error'). Apex already auto-protects revenue/refunds/errors by default — only pass this to add a test-specific harm signal. Use list_metrics({role:'guardrail'}) to see what's firing. Choose one ORTHOGONAL to the primary goal."),
|
|
336
|
+
guardrailType: z.enum(["rate", "revenue", "duration"]).optional().describe("How the guardrail is measured. 'rate' (default) = how often the harm event fires. 'revenue' = sum a value (e.g. refund $)."),
|
|
337
|
+
guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
|
|
306
338
|
predictionMagnitude: z.string().optional().describe("Your committed prediction of the effect, e.g. '10% lift'. Recorded with 'agent' provenance and used for calibration. Required before the experiment can be ACTIVATED."),
|
|
307
339
|
mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
|
|
308
340
|
surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' (default) for a website, 'mobile' for a native / Capacitor / React-Native app. If the repo is a mobile app (has capacitor.config.* or @apex-inc/capacitor-plugin), set 'mobile' for ALL its experiments — even ones resolved via useApexVariant in the web layer — so they group under the app, not a website. This sets the dashboard data-source label (Website vs Mobile app)."),
|
|
@@ -495,19 +527,28 @@ export const toolDefinitions = {
|
|
|
495
527
|
primaryMetric,
|
|
496
528
|
allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
|
|
497
529
|
secondaryMetrics: [],
|
|
498
|
-
// Explicit guardrail when the agent named one (orthogonal to the
|
|
499
|
-
//
|
|
530
|
+
// Explicit EXTRA guardrail when the agent named one (orthogonal to the
|
|
531
|
+
// goal). When omitted, the server auto-attaches the conversion-model
|
|
532
|
+
// vital signs ("protected by default").
|
|
500
533
|
...(args.guardrailEvent
|
|
501
534
|
? {
|
|
502
535
|
guardrailMetrics: [
|
|
503
|
-
{
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
536
|
+
(() => {
|
|
537
|
+
const gType = args.guardrailType ?? "rate";
|
|
538
|
+
const isValue = gType === "revenue" || gType === "duration";
|
|
539
|
+
return {
|
|
540
|
+
key: `${args.guardrailEvent}_${gType}`,
|
|
541
|
+
label: `${titleCaseEvent(args.guardrailEvent)} ${gType === "rate" ? "rate" : gType}`,
|
|
542
|
+
type: gType,
|
|
543
|
+
unit: gType === "revenue" ? "USD" : gType === "duration" ? "s" : "%",
|
|
544
|
+
direction: "decrease",
|
|
545
|
+
source: isValue
|
|
546
|
+
? { kind: "event", eventType: args.guardrailEvent, property: "value" }
|
|
547
|
+
: { kind: "event", eventType: args.guardrailEvent },
|
|
548
|
+
guardrailThreshold: args.guardrailThreshold ?? 0.25,
|
|
549
|
+
thresholdKind: "relative",
|
|
550
|
+
};
|
|
551
|
+
})(),
|
|
511
552
|
],
|
|
512
553
|
}
|
|
513
554
|
: {}),
|
|
@@ -1150,15 +1191,19 @@ export const toolDefinitions = {
|
|
|
1150
1191
|
},
|
|
1151
1192
|
},
|
|
1152
1193
|
list_metrics: {
|
|
1153
|
-
description: `${APEX} — List the canonical metric/goal catalog for this workspace: typed metrics (rate/revenue/duration) with whether each is DETECTED (firing, with volume) and its outcome tier. Use this to pick a measurable OUTCOME goal (purchase/signup) over a proxy (email_open) before create_experiment.`,
|
|
1194
|
+
description: `${APEX} — List the canonical metric/goal catalog for this workspace: typed metrics (rate/revenue/duration) with whether each is DETECTED (firing, with volume) and its outcome tier. Use this to pick a measurable OUTCOME goal (purchase/signup) over a proxy (email_open) before create_experiment, or role:'guardrail' to see protective harm signals.`,
|
|
1154
1195
|
schema: z.object({
|
|
1155
1196
|
onlyMeasurable: z
|
|
1156
1197
|
.boolean()
|
|
1157
1198
|
.optional()
|
|
1158
1199
|
.describe("Only return metrics the engine can estimate today (default true)."),
|
|
1200
|
+
role: z
|
|
1201
|
+
.enum(["goal", "guardrail"])
|
|
1202
|
+
.optional()
|
|
1203
|
+
.describe("Filter to goal metrics (optimize) or guardrail metrics (protect against harm)."),
|
|
1159
1204
|
}),
|
|
1160
|
-
handler: async ({ onlyMeasurable }) => {
|
|
1161
|
-
const res = await apiGet("/api/metrics/catalog");
|
|
1205
|
+
handler: async ({ onlyMeasurable, role }) => {
|
|
1206
|
+
const res = await apiGet(role ? `/api/metrics/catalog?role=${role}` : "/api/metrics/catalog");
|
|
1162
1207
|
const all = res.data ?? [];
|
|
1163
1208
|
const list = (onlyMeasurable === false ? all : all.filter((m) => m.measurable)).slice(0, 60);
|
|
1164
1209
|
if (list.length === 0) {
|
|
@@ -1197,6 +1242,165 @@ export const toolDefinitions = {
|
|
|
1197
1242
|
};
|
|
1198
1243
|
},
|
|
1199
1244
|
},
|
|
1245
|
+
update_experiment: {
|
|
1246
|
+
description: `${APEX} — Edit an EXISTING experiment. Editability is gated by lifecycle: a DRAFT (with no exposures yet) is fully editable; once it has data or is running, the pre-registration (hypothesis, metric, guardrails) and bucketing (surface, variants) freeze and edits return a clear "experiment_locked" reason. After freeze, fork_experiment instead. Use this to fix a mislabeled surface or correct a draft before launch.`,
|
|
1247
|
+
schema: z.object({
|
|
1248
|
+
experimentId: z.string().describe("The experiment id to edit."),
|
|
1249
|
+
name: z.string().optional().describe("Rename (cosmetic; editable anytime)."),
|
|
1250
|
+
surface: z.enum(["web", "mobile"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile)."),
|
|
1251
|
+
hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
|
|
1252
|
+
primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
|
|
1253
|
+
primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
|
|
1254
|
+
guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
|
|
1255
|
+
}),
|
|
1256
|
+
handler: async (args) => {
|
|
1257
|
+
const body = { id: args.experimentId };
|
|
1258
|
+
if (args.name !== undefined)
|
|
1259
|
+
body.name = args.name;
|
|
1260
|
+
if (args.surface !== undefined)
|
|
1261
|
+
body.surface = args.surface;
|
|
1262
|
+
if (args.hypothesis !== undefined)
|
|
1263
|
+
body.hypothesis = args.hypothesis;
|
|
1264
|
+
if (args.primaryMetricEvent) {
|
|
1265
|
+
const t = args.primaryMetricType ?? "rate";
|
|
1266
|
+
const isValue = t === "revenue" || t === "duration";
|
|
1267
|
+
body.primaryMetric = {
|
|
1268
|
+
key: `${args.primaryMetricEvent}_${t}`,
|
|
1269
|
+
label: titleCaseEvent(args.primaryMetricEvent),
|
|
1270
|
+
type: t,
|
|
1271
|
+
unit: t === "revenue" ? "USD" : t === "duration" ? "s" : "%",
|
|
1272
|
+
direction: "increase",
|
|
1273
|
+
source: isValue
|
|
1274
|
+
? { kind: "event", eventType: args.primaryMetricEvent, property: "value" }
|
|
1275
|
+
: { kind: "event", eventType: args.primaryMetricEvent },
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (args.guardrailEvent) {
|
|
1279
|
+
body.guardrailMetrics = [
|
|
1280
|
+
{
|
|
1281
|
+
key: `${args.guardrailEvent}_rate`,
|
|
1282
|
+
label: `${titleCaseEvent(args.guardrailEvent)} rate`,
|
|
1283
|
+
type: "rate",
|
|
1284
|
+
unit: "%",
|
|
1285
|
+
direction: "decrease",
|
|
1286
|
+
source: { kind: "event", eventType: args.guardrailEvent },
|
|
1287
|
+
guardrailThreshold: 0.25,
|
|
1288
|
+
thresholdKind: "relative",
|
|
1289
|
+
},
|
|
1290
|
+
];
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const updated = await apiPatch("/api/experiments", body);
|
|
1294
|
+
// Tenant echo: never report success for a record outside the active workspace.
|
|
1295
|
+
const active = getActiveWorkspace();
|
|
1296
|
+
if (active && updated.workspaceKey && updated.workspaceKey !== active) {
|
|
1297
|
+
return {
|
|
1298
|
+
content: [{ type: "text", text: `${APEX} Refusing to confirm: the updated record belongs to a different workspace.` }],
|
|
1299
|
+
isError: true,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
const applied = Object.keys(body).filter((k) => k !== "id");
|
|
1303
|
+
return {
|
|
1304
|
+
content: [
|
|
1305
|
+
{
|
|
1306
|
+
type: "text",
|
|
1307
|
+
text: `${APEX} Updated "${updated.name}" (${updated.surface}, ${updated.status}). Applied: ${applied.join(", ") || "nothing"}.`,
|
|
1308
|
+
},
|
|
1309
|
+
],
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
catch (err) {
|
|
1313
|
+
// Surface the lock reason verbatim (409 experiment_locked etc.).
|
|
1314
|
+
return {
|
|
1315
|
+
content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
|
|
1316
|
+
isError: true,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
},
|
|
1320
|
+
},
|
|
1321
|
+
fork_experiment: {
|
|
1322
|
+
description: `${APEX} — Duplicate a frozen experiment's design into a fresh editable DRAFT. Use this after update_experiment reports "experiment_locked" (running or has exposures). The fork carries NO data (no results, assignments, or winner) and records its lineage; edit it freely, then activate.`,
|
|
1323
|
+
schema: z.object({
|
|
1324
|
+
experimentId: z.string().describe("The experiment id to fork."),
|
|
1325
|
+
}),
|
|
1326
|
+
handler: async ({ experimentId }) => {
|
|
1327
|
+
try {
|
|
1328
|
+
const forked = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/fork`, {});
|
|
1329
|
+
const active = getActiveWorkspace();
|
|
1330
|
+
if (active && forked.workspaceKey && forked.workspaceKey !== active) {
|
|
1331
|
+
return {
|
|
1332
|
+
content: [{ type: "text", text: `${APEX} Refusing to confirm: the fork belongs to a different workspace.` }],
|
|
1333
|
+
isError: true,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
content: [
|
|
1338
|
+
{
|
|
1339
|
+
type: "text",
|
|
1340
|
+
text: `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}). Edit it with update_experiment, then activate.`,
|
|
1341
|
+
},
|
|
1342
|
+
],
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
catch (err) {
|
|
1346
|
+
return {
|
|
1347
|
+
content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
|
|
1348
|
+
isError: true,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
},
|
|
1353
|
+
get_growth_reality: {
|
|
1354
|
+
description: `${APEX} — "Is our growth real?" Per goal, the cumulative lift of everything Apex did (journeys + experiments) vs a persistent do-nothing global holdout, with a 95% CI. The honest, holdout-gated answer to "would this have happened anyway?" Forward-only; goals still collecting are flagged, not faked.`,
|
|
1355
|
+
schema: z.object({
|
|
1356
|
+
days: z
|
|
1357
|
+
.union([z.number(), z.literal("all")])
|
|
1358
|
+
.optional()
|
|
1359
|
+
.describe("Trailing window in days, or 'all' for since-inception (default)."),
|
|
1360
|
+
}),
|
|
1361
|
+
handler: async ({ days }) => {
|
|
1362
|
+
const q = days && days !== "all" ? `?days=${days}` : "?days=all";
|
|
1363
|
+
const res = await apiGet(`/api/growth/reality${q}`);
|
|
1364
|
+
const goals = res.data?.goals ?? [];
|
|
1365
|
+
if (goals.length === 0) {
|
|
1366
|
+
return {
|
|
1367
|
+
content: [
|
|
1368
|
+
{
|
|
1369
|
+
type: "text",
|
|
1370
|
+
text: `${APEX} No growth-reality data yet. Counting is forward-only from when the global holdout went live — check back once exposed + holdout cohorts are flowing.`,
|
|
1371
|
+
},
|
|
1372
|
+
],
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
const pctFmt = (v) => v == null ? "—" : `${v > 0 ? "+" : ""}${(v * 100).toFixed(1)}%`;
|
|
1376
|
+
const lines = goals.map((g) => {
|
|
1377
|
+
if (!g.isDecisive) {
|
|
1378
|
+
return ` • ${g.goalName}: COLLECTING (directional ${pctFmt(g.lift)}, since ${g.collectingSince || "—"})`;
|
|
1379
|
+
}
|
|
1380
|
+
const ci = g.liftCI
|
|
1381
|
+
? ` [${pctFmt(g.liftCI.lower)}, ${pctFmt(g.liftCI.upper)}]`
|
|
1382
|
+
: "";
|
|
1383
|
+
const rev = typeof g.incrementalRevenue === "number"
|
|
1384
|
+
? ` · ~$${Math.round(g.incrementalRevenue).toLocaleString()} incremental`
|
|
1385
|
+
: "";
|
|
1386
|
+
return ` • ${g.goalName}: ${pctFmt(g.lift)} vs holdout${ci} — DECISIVE${rev}`;
|
|
1387
|
+
});
|
|
1388
|
+
return {
|
|
1389
|
+
content: [
|
|
1390
|
+
{
|
|
1391
|
+
type: "text",
|
|
1392
|
+
text: [
|
|
1393
|
+
`${APEX} Is our growth real? (vs ${res.data?.holdoutPct ?? 5}% do-nothing holdout)`,
|
|
1394
|
+
`${"═".repeat(40)}`,
|
|
1395
|
+
...lines,
|
|
1396
|
+
``,
|
|
1397
|
+
`Decisive = both cohorts cleared the minimum size AND the CI excludes zero. Everything else is directional, not proven.`,
|
|
1398
|
+
].join("\n"),
|
|
1399
|
+
},
|
|
1400
|
+
],
|
|
1401
|
+
};
|
|
1402
|
+
},
|
|
1403
|
+
},
|
|
1200
1404
|
suggest_experiment: {
|
|
1201
1405
|
description: `${APEX} — Get smart experiment suggestions based on context. Analyzes your assumptions, past experiments, and confidence gaps to recommend what to test next.`,
|
|
1202
1406
|
schema: z.object({
|
|
@@ -2472,6 +2676,143 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2472
2676
|
// surfaces shipped in `journey_exit_semantics_0b4fe496.plan.md`.
|
|
2473
2677
|
// Agent-friendly arg names (snake_case) are mapped to the REST
|
|
2474
2678
|
// routes inside each handler.
|
|
2679
|
+
create_journey: {
|
|
2680
|
+
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.`,
|
|
2681
|
+
schema: z.object({ name: z.string().optional().describe("Journey name (default 'Untitled journey').") }),
|
|
2682
|
+
handler: async ({ name }) => {
|
|
2683
|
+
try {
|
|
2684
|
+
const j = await apiPost("/api/journeys/blank", { name });
|
|
2685
|
+
if (!tenantOk(j.workspaceKey))
|
|
2686
|
+
return tenantMismatch();
|
|
2687
|
+
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.` }] };
|
|
2688
|
+
}
|
|
2689
|
+
catch (err) {
|
|
2690
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2691
|
+
}
|
|
2692
|
+
},
|
|
2693
|
+
},
|
|
2694
|
+
create_journey_from_template: {
|
|
2695
|
+
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.`,
|
|
2696
|
+
schema: z.object({ templateId: z.string().describe("Template id from get_wiring_status (e.g. 'mobile-cart-recovery').") }),
|
|
2697
|
+
handler: async ({ templateId }) => {
|
|
2698
|
+
try {
|
|
2699
|
+
const j = await apiPost("/api/journeys/from-template", { templateId });
|
|
2700
|
+
if (!tenantOk(j.workspaceKey))
|
|
2701
|
+
return tenantMismatch();
|
|
2702
|
+
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.` }] };
|
|
2703
|
+
}
|
|
2704
|
+
catch (err) {
|
|
2705
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2706
|
+
}
|
|
2707
|
+
},
|
|
2708
|
+
},
|
|
2709
|
+
set_journey_trigger: {
|
|
2710
|
+
description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass a triggerContractId (a blank journey seeds the starter contracts; get_journey shows the current value).`,
|
|
2711
|
+
schema: z.object({
|
|
2712
|
+
journeyId: z.string(),
|
|
2713
|
+
triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on."),
|
|
2714
|
+
}),
|
|
2715
|
+
handler: async ({ journeyId, triggerContractId }) => {
|
|
2716
|
+
try {
|
|
2717
|
+
const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
|
|
2718
|
+
const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
|
|
2719
|
+
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
|
|
2720
|
+
if (!tenantOk(updated.workspaceKey))
|
|
2721
|
+
return tenantMismatch();
|
|
2722
|
+
return { content: [{ type: "text", text: `${APEX} Trigger set to contract ${triggerContractId} on journey ${journeyId}. View it: ${journeyLink(journeyId)}` }] };
|
|
2723
|
+
}
|
|
2724
|
+
catch (err) {
|
|
2725
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2726
|
+
}
|
|
2727
|
+
},
|
|
2728
|
+
},
|
|
2729
|
+
add_journey_step: {
|
|
2730
|
+
description: `${APEX} — Append a wait or send step to a draft journey (inserted just before the exit; linear flows). 'wait' delays (ISO-8601 duration, e.g. P1D = 24h); 'send' fires a communication on the given channels. Call repeatedly to build trigger → wait → send → exit.`,
|
|
2731
|
+
schema: z.object({
|
|
2732
|
+
journeyId: z.string(),
|
|
2733
|
+
kind: z.enum(["wait", "send"]),
|
|
2734
|
+
durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
|
|
2735
|
+
commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
|
|
2736
|
+
commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
|
|
2737
|
+
channels: z.array(z.enum(["email", "in_app", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email'])."),
|
|
2738
|
+
}),
|
|
2739
|
+
handler: async (args) => {
|
|
2740
|
+
try {
|
|
2741
|
+
if (args.kind === "send" && !args.commId) {
|
|
2742
|
+
return { content: [{ type: "text", text: `${APEX} A send step needs commId.` }], isError: true };
|
|
2743
|
+
}
|
|
2744
|
+
const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
|
|
2745
|
+
const steps = [...(j.steps ?? [])];
|
|
2746
|
+
const exit = steps.find((s) => s.type === "exit");
|
|
2747
|
+
if (!exit) {
|
|
2748
|
+
return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert.` }], isError: true };
|
|
2749
|
+
}
|
|
2750
|
+
const exitId = exit.id;
|
|
2751
|
+
const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
2752
|
+
const newStep = args.kind === "wait"
|
|
2753
|
+
? { id: newId, type: "wait", label: "Wait", mode: "duration", duration: args.durationIso ?? "P1D", next: exitId }
|
|
2754
|
+
: { id: newId, type: "send", label: "Send", commId: args.commId, commVersion: args.commVersion ?? 1, channels: args.channels ?? ["email"], next: exitId };
|
|
2755
|
+
// Insert before the exit: rewire whatever currently points to exit.
|
|
2756
|
+
const pre = steps.find((s) => s.next === exitId);
|
|
2757
|
+
if (pre)
|
|
2758
|
+
pre.next = newId;
|
|
2759
|
+
steps.push(newStep);
|
|
2760
|
+
const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
|
|
2761
|
+
if (!tenantOk(updated.workspaceKey))
|
|
2762
|
+
return tenantMismatch();
|
|
2763
|
+
return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
|
|
2764
|
+
}
|
|
2765
|
+
catch (err) {
|
|
2766
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2767
|
+
}
|
|
2768
|
+
},
|
|
2769
|
+
},
|
|
2770
|
+
publish_journey: {
|
|
2771
|
+
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.`,
|
|
2772
|
+
schema: z.object({
|
|
2773
|
+
journeyId: z.string(),
|
|
2774
|
+
confirmLive: z.boolean().optional().describe("Set true to publish for real. Omitted/false = dry-run validation only."),
|
|
2775
|
+
}),
|
|
2776
|
+
handler: async ({ journeyId, confirmLive }) => {
|
|
2777
|
+
const path = `/api/journeys/${encodeURIComponent(journeyId)}/publish`;
|
|
2778
|
+
if (!confirmLive) {
|
|
2779
|
+
try {
|
|
2780
|
+
await apiPost(`${path}?dryRun=true`, {});
|
|
2781
|
+
return { content: [{ type: "text", text: `${APEX} Dry-run passed — journey ${journeyId} is valid and ready. Re-call with confirmLive:true to publish it to live customers.` }] };
|
|
2782
|
+
}
|
|
2783
|
+
catch (err) {
|
|
2784
|
+
return { content: [{ type: "text", text: `${APEX} Dry-run found a problem before publishing: ${errMsg(err)}` }], isError: true };
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
try {
|
|
2788
|
+
const res = await apiPost(path, {});
|
|
2789
|
+
if (!tenantOk(res.workspaceKey))
|
|
2790
|
+
return tenantMismatch();
|
|
2791
|
+
return { content: [{ type: "text", text: `${APEX} Published journey ${journeyId} — it is now live on trigger events. View it: ${journeyLink(journeyId)}` }] };
|
|
2792
|
+
}
|
|
2793
|
+
catch (err) {
|
|
2794
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2795
|
+
}
|
|
2796
|
+
},
|
|
2797
|
+
},
|
|
2798
|
+
create_communication: {
|
|
2799
|
+
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).`,
|
|
2800
|
+
schema: z.object({
|
|
2801
|
+
title: z.string().optional(),
|
|
2802
|
+
channels: z.array(z.enum(["email", "in_app_push", "mobile_push"])).optional(),
|
|
2803
|
+
}),
|
|
2804
|
+
handler: async ({ title, channels }) => {
|
|
2805
|
+
try {
|
|
2806
|
+
const comm = await apiPost("/api/communications/blank", { title, channels });
|
|
2807
|
+
if (!tenantOk(comm.workspaceKey))
|
|
2808
|
+
return tenantMismatch();
|
|
2809
|
+
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.` }] };
|
|
2810
|
+
}
|
|
2811
|
+
catch (err) {
|
|
2812
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
2813
|
+
}
|
|
2814
|
+
},
|
|
2815
|
+
},
|
|
2475
2816
|
list_journeys: {
|
|
2476
2817
|
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.`,
|
|
2477
2818
|
schema: z.object({}),
|
|
@@ -2781,8 +3122,8 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
2781
3122
|
const readyTemplates = d.templates.filter((t) => t.ready);
|
|
2782
3123
|
const blockedTemplates = d.templates.filter((t) => !t.ready);
|
|
2783
3124
|
const templateLines = [
|
|
2784
|
-
...readyTemplates.map((t) => `- ✅ ${t.title} (${t.templateKind}) — Ready to run`),
|
|
2785
|
-
...blockedTemplates.slice(0, 10).map((t) => `- ⏳ ${t.title} (${t.templateKind}) — needs ${[...t.missingEvents, ...t.missingTraits].join(", ") || "?"}`),
|
|
3125
|
+
...readyTemplates.map((t) => `- ✅ ${t.title} (${t.templateKind}, id: ${t.templateId}) — Ready to run${t.templateKind === "journey" ? ` → create_journey_from_template({ templateId: "${t.templateId}" })` : ""}`),
|
|
3126
|
+
...blockedTemplates.slice(0, 10).map((t) => `- ⏳ ${t.title} (${t.templateKind}, id: ${t.templateId}) — needs ${[...t.missingEvents, ...t.missingTraits].join(", ") || "?"}`),
|
|
2786
3127
|
];
|
|
2787
3128
|
const milestones = [
|
|
2788
3129
|
d.milestones.firstEventAt ? `First event: ${d.milestones.firstEventAt}` : "First event: not yet",
|