@apex-inc/mcp-server 0.9.14 → 0.11.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/tools.js CHANGED
@@ -51,6 +51,28 @@ 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
+ /**
58
+ * Tenant echo (council CISO): never report a mutation as successful for a record
59
+ * outside the active workspace (guards a leaked/cross-workspace key).
60
+ */
61
+ function tenantOk(workspaceKey) {
62
+ const active = getActiveWorkspace();
63
+ return !active || !workspaceKey || workspaceKey === active;
64
+ }
65
+ function tenantMismatch() {
66
+ return {
67
+ content: [
68
+ {
69
+ type: "text",
70
+ text: `${APEX} Refusing to confirm: the affected record belongs to a different workspace.`,
71
+ },
72
+ ],
73
+ isError: true,
74
+ };
75
+ }
54
76
  /**
55
77
  * Shared schema + handler for `switch_workspace` (canonical) and its
56
78
  * deprecated alias `switch_project`. Both register against the same handler
@@ -299,7 +321,13 @@ export const toolDefinitions = {
299
321
  beliefStatement: z.string().optional().describe("If you don't have a beliefId, pass the assumption statement and Apex will create the belief (and a hypothesis) and link them to this experiment."),
300
322
  beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
301
323
  hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
302
- 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."),
324
+ 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."),
325
+ 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."),
326
+ primaryMetricValueProperty: z.string().optional().describe("For revenue/duration goals: the event property to sum, e.g. 'value'. Defaults to 'value'."),
327
+ 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."),
328
+ 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 $)."),
329
+ guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
330
+ 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."),
303
331
  mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
304
332
  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)."),
305
333
  dataSourceId: z.string().optional().describe("Optional explicit Data Source (property) id this experiment runs in, e.g. 'ds_website' or 'ds_ios'. Usually inferred from surface; pass it to bind to a specific registered source. Tenant-validated server-side."),
@@ -349,13 +377,18 @@ export const toolDefinitions = {
349
377
  isError: true,
350
378
  };
351
379
  }
380
+ const metricType = args.primaryMetricType ?? "rate";
381
+ const isValue = metricType === "revenue" || metricType === "duration";
382
+ const valueProperty = args.primaryMetricValueProperty ?? "value";
352
383
  primaryMetric = {
353
- key: `${event}_rate`,
354
- label: `${titleCaseEvent(event)} rate`,
355
- type: "rate",
356
- unit: "%",
384
+ key: `${event}_${metricType}`,
385
+ label: `${titleCaseEvent(event)} ${metricType}`,
386
+ type: metricType,
387
+ unit: metricType === "revenue" ? "USD" : metricType === "duration" ? "s" : "%",
357
388
  direction: "increase",
358
- source: { kind: "event", eventType: event },
389
+ source: isValue
390
+ ? { kind: "event", eventType: event, property: valueProperty }
391
+ : { kind: "event", eventType: event },
359
392
  };
360
393
  }
361
394
  if (isPreview) {
@@ -471,7 +504,14 @@ export const toolDefinitions = {
471
504
  ...(args.dataSourceId ? { dataSourceId: args.dataSourceId } : {}),
472
505
  ...(args.randomizationUnit ? { randomizationUnit: args.randomizationUnit } : {}),
473
506
  hypothesis: resolvedHypothesis,
474
- prediction: { direction: "increase", magnitude: "" },
507
+ // Agent-authored: tagged "agent" provenance so calibration attributes it
508
+ // correctly. When the agent supplies predictionMagnitude it satisfies the
509
+ // activation gate; an empty magnitude leaves the gate asking for one.
510
+ prediction: {
511
+ direction: "increase",
512
+ magnitude: args.predictionMagnitude?.trim() ?? "",
513
+ provenance: "agent",
514
+ },
475
515
  confidence: 50,
476
516
  beliefId,
477
517
  hypothesisId,
@@ -479,7 +519,31 @@ export const toolDefinitions = {
479
519
  primaryMetric,
480
520
  allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
481
521
  secondaryMetrics: [],
482
- guardrailMetrics: [],
522
+ // Explicit EXTRA guardrail when the agent named one (orthogonal to the
523
+ // goal). When omitted, the server auto-attaches the conversion-model
524
+ // vital signs ("protected by default").
525
+ ...(args.guardrailEvent
526
+ ? {
527
+ guardrailMetrics: [
528
+ (() => {
529
+ const gType = args.guardrailType ?? "rate";
530
+ const isValue = gType === "revenue" || gType === "duration";
531
+ return {
532
+ key: `${args.guardrailEvent}_${gType}`,
533
+ label: `${titleCaseEvent(args.guardrailEvent)} ${gType === "rate" ? "rate" : gType}`,
534
+ type: gType,
535
+ unit: gType === "revenue" ? "USD" : gType === "duration" ? "s" : "%",
536
+ direction: "decrease",
537
+ source: isValue
538
+ ? { kind: "event", eventType: args.guardrailEvent, property: "value" }
539
+ : { kind: "event", eventType: args.guardrailEvent },
540
+ guardrailThreshold: args.guardrailThreshold ?? 0.25,
541
+ thresholdKind: "relative",
542
+ };
543
+ })(),
544
+ ],
545
+ }
546
+ : {}),
483
547
  attributionWindow: { unit: "hours", value: 24, startFrom: "first_interaction" },
484
548
  variants,
485
549
  });
@@ -547,6 +611,19 @@ export const toolDefinitions = {
547
611
  `9. Call track_deployment with the experiment ID and commit SHA`,
548
612
  `10. Call verify_experiment_wiring, then activate_experiment once both arms report exposures`,
549
613
  ];
614
+ // Autonomous-agent activation policy (Phase 6): the experiment is created
615
+ // as a DRAFT. Activating it spends metered usage and exposes real users,
616
+ // so an agent must NOT auto-activate — surface the cost and get explicit
617
+ // human approval first. The server's activation gate also independently
618
+ // requires the full spine (measurable goal + guardrail + prediction).
619
+ const activationPolicy = [
620
+ "",
621
+ "ACTIVATION POLICY — do not auto-activate:",
622
+ "- This experiment is a DRAFT. It is NOT collecting data yet.",
623
+ "- Activating meters usage (one billable event per variant assignment) and shows variants to real users.",
624
+ "- Before calling activate_experiment, tell the user the expected metered volume and get their explicit go-ahead.",
625
+ "- Activation also requires a measurable primary goal, a guardrail, and a committed prediction (the server enforces this). If you didn't pass predictionMagnitude, add one first.",
626
+ ];
550
627
  const recipe = {
551
628
  _apex: true,
552
629
  _type: "experiment_created",
@@ -583,14 +660,15 @@ export const toolDefinitions = {
583
660
  ? groundingGuidance
584
661
  .concat(isMobile ? mobileSteps : webSteps)
585
662
  .concat(styleGuidance)
663
+ .concat(activationPolicy)
586
664
  .join("\n")
587
665
  : groundingGuidance
588
666
  .concat([
589
667
  "SNIPPET MODE — no code changes needed.",
590
668
  "The experiment will be applied via the Apex snippet at runtime.",
591
669
  `Preview: ${previewUrl}`,
592
- "Ask the user to activate when ready.",
593
670
  ])
671
+ .concat(activationPolicy)
594
672
  .join("\n"),
595
673
  };
596
674
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
@@ -1051,6 +1129,270 @@ export const toolDefinitions = {
1051
1129
  };
1052
1130
  },
1053
1131
  },
1132
+ list_beliefs: {
1133
+ description: `${APEX} — List the workspace's beliefs (the assumptions experiments test). Shows confidence, validation state, and AI-generated provenance so you can LINK an existing belief instead of minting a duplicate.`,
1134
+ schema: z.object({
1135
+ query: z
1136
+ .string()
1137
+ .optional()
1138
+ .describe("Filter beliefs by a keyword in the statement"),
1139
+ }),
1140
+ handler: async ({ query }) => {
1141
+ const beliefs = await apiGet("/api/beliefs");
1142
+ const q = query?.toLowerCase().trim();
1143
+ const filtered = q
1144
+ ? beliefs.filter((b) => b.statement.toLowerCase().includes(q))
1145
+ : beliefs;
1146
+ if (filtered.length === 0) {
1147
+ return {
1148
+ content: [
1149
+ {
1150
+ type: "text",
1151
+ text: `${APEX} No beliefs${q ? ` matching "${query}"` : ""} yet. You can generate one from a hypothesis when creating an experiment.`,
1152
+ },
1153
+ ],
1154
+ };
1155
+ }
1156
+ const lines = filtered
1157
+ .slice(0, 50)
1158
+ .map((b) => {
1159
+ const tags = [
1160
+ `${Math.round(b.confidence * 100)}% confidence`,
1161
+ b.validationState === "unvalidated" ? "unvalidated" : null,
1162
+ b.generatedFromExperimentId ? "AI-generated" : null,
1163
+ ]
1164
+ .filter(Boolean)
1165
+ .join(", ");
1166
+ return ` • [${b.id}] ${b.statement} (${tags})`;
1167
+ })
1168
+ .join("\n");
1169
+ return {
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: [
1174
+ `${APEX} Beliefs (${filtered.length})`,
1175
+ `${"═".repeat(40)}`,
1176
+ lines,
1177
+ ``,
1178
+ `Link one to a new experiment with its id, or generate a fresh belief from your hypothesis.`,
1179
+ ].join("\n"),
1180
+ },
1181
+ ],
1182
+ };
1183
+ },
1184
+ },
1185
+ list_metrics: {
1186
+ 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.`,
1187
+ schema: z.object({
1188
+ onlyMeasurable: z
1189
+ .boolean()
1190
+ .optional()
1191
+ .describe("Only return metrics the engine can estimate today (default true)."),
1192
+ role: z
1193
+ .enum(["goal", "guardrail"])
1194
+ .optional()
1195
+ .describe("Filter to goal metrics (optimize) or guardrail metrics (protect against harm)."),
1196
+ }),
1197
+ handler: async ({ onlyMeasurable, role }) => {
1198
+ const res = await apiGet(role ? `/api/metrics/catalog?role=${role}` : "/api/metrics/catalog");
1199
+ const all = res.data ?? [];
1200
+ const list = (onlyMeasurable === false ? all : all.filter((m) => m.measurable)).slice(0, 60);
1201
+ if (list.length === 0) {
1202
+ return {
1203
+ content: [{ type: "text", text: `${APEX} No metrics in the catalog yet.` }],
1204
+ };
1205
+ }
1206
+ const lines = list
1207
+ .map((m) => {
1208
+ const tags = [
1209
+ m.type,
1210
+ m.outcomeTier === "proximal" ? "proxy" : m.outcomeTier,
1211
+ m.wired === "detected"
1212
+ ? `DETECTED${m.trailingVolume ? ` (${m.trailingVolume})` : ""}`
1213
+ : "not firing",
1214
+ m.measurable ? null : "not measurable",
1215
+ ]
1216
+ .filter(Boolean)
1217
+ .join(", ");
1218
+ return ` • ${m.key} — ${m.label} (${tags})`;
1219
+ })
1220
+ .join("\n");
1221
+ return {
1222
+ content: [
1223
+ {
1224
+ type: "text",
1225
+ text: [
1226
+ `${APEX} Metric catalog (${list.length})`,
1227
+ `${"═".repeat(40)}`,
1228
+ lines,
1229
+ ``,
1230
+ `Prefer a DETECTED outcome metric. Pass its event to create_experiment as primaryMetricEvent (+ primaryMetricType 'revenue' for value goals).`,
1231
+ ].join("\n"),
1232
+ },
1233
+ ],
1234
+ };
1235
+ },
1236
+ },
1237
+ update_experiment: {
1238
+ 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.`,
1239
+ schema: z.object({
1240
+ experimentId: z.string().describe("The experiment id to edit."),
1241
+ name: z.string().optional().describe("Rename (cosmetic; editable anytime)."),
1242
+ surface: z.enum(["web", "mobile"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile)."),
1243
+ hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
1244
+ primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
1245
+ primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
1246
+ guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
1247
+ }),
1248
+ handler: async (args) => {
1249
+ const body = { id: args.experimentId };
1250
+ if (args.name !== undefined)
1251
+ body.name = args.name;
1252
+ if (args.surface !== undefined)
1253
+ body.surface = args.surface;
1254
+ if (args.hypothesis !== undefined)
1255
+ body.hypothesis = args.hypothesis;
1256
+ if (args.primaryMetricEvent) {
1257
+ const t = args.primaryMetricType ?? "rate";
1258
+ const isValue = t === "revenue" || t === "duration";
1259
+ body.primaryMetric = {
1260
+ key: `${args.primaryMetricEvent}_${t}`,
1261
+ label: titleCaseEvent(args.primaryMetricEvent),
1262
+ type: t,
1263
+ unit: t === "revenue" ? "USD" : t === "duration" ? "s" : "%",
1264
+ direction: "increase",
1265
+ source: isValue
1266
+ ? { kind: "event", eventType: args.primaryMetricEvent, property: "value" }
1267
+ : { kind: "event", eventType: args.primaryMetricEvent },
1268
+ };
1269
+ }
1270
+ if (args.guardrailEvent) {
1271
+ body.guardrailMetrics = [
1272
+ {
1273
+ key: `${args.guardrailEvent}_rate`,
1274
+ label: `${titleCaseEvent(args.guardrailEvent)} rate`,
1275
+ type: "rate",
1276
+ unit: "%",
1277
+ direction: "decrease",
1278
+ source: { kind: "event", eventType: args.guardrailEvent },
1279
+ guardrailThreshold: 0.25,
1280
+ thresholdKind: "relative",
1281
+ },
1282
+ ];
1283
+ }
1284
+ try {
1285
+ const updated = await apiPatch("/api/experiments", body);
1286
+ // Tenant echo: never report success for a record outside the active workspace.
1287
+ const active = getActiveWorkspace();
1288
+ if (active && updated.workspaceKey && updated.workspaceKey !== active) {
1289
+ return {
1290
+ content: [{ type: "text", text: `${APEX} Refusing to confirm: the updated record belongs to a different workspace.` }],
1291
+ isError: true,
1292
+ };
1293
+ }
1294
+ const applied = Object.keys(body).filter((k) => k !== "id");
1295
+ return {
1296
+ content: [
1297
+ {
1298
+ type: "text",
1299
+ text: `${APEX} Updated "${updated.name}" (${updated.surface}, ${updated.status}). Applied: ${applied.join(", ") || "nothing"}.`,
1300
+ },
1301
+ ],
1302
+ };
1303
+ }
1304
+ catch (err) {
1305
+ // Surface the lock reason verbatim (409 experiment_locked etc.).
1306
+ return {
1307
+ content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
1308
+ isError: true,
1309
+ };
1310
+ }
1311
+ },
1312
+ },
1313
+ fork_experiment: {
1314
+ 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.`,
1315
+ schema: z.object({
1316
+ experimentId: z.string().describe("The experiment id to fork."),
1317
+ }),
1318
+ handler: async ({ experimentId }) => {
1319
+ try {
1320
+ const forked = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/fork`, {});
1321
+ const active = getActiveWorkspace();
1322
+ if (active && forked.workspaceKey && forked.workspaceKey !== active) {
1323
+ return {
1324
+ content: [{ type: "text", text: `${APEX} Refusing to confirm: the fork belongs to a different workspace.` }],
1325
+ isError: true,
1326
+ };
1327
+ }
1328
+ return {
1329
+ content: [
1330
+ {
1331
+ type: "text",
1332
+ text: `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}). Edit it with update_experiment, then activate.`,
1333
+ },
1334
+ ],
1335
+ };
1336
+ }
1337
+ catch (err) {
1338
+ return {
1339
+ content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
1340
+ isError: true,
1341
+ };
1342
+ }
1343
+ },
1344
+ },
1345
+ get_growth_reality: {
1346
+ 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.`,
1347
+ schema: z.object({
1348
+ days: z
1349
+ .union([z.number(), z.literal("all")])
1350
+ .optional()
1351
+ .describe("Trailing window in days, or 'all' for since-inception (default)."),
1352
+ }),
1353
+ handler: async ({ days }) => {
1354
+ const q = days && days !== "all" ? `?days=${days}` : "?days=all";
1355
+ const res = await apiGet(`/api/growth/reality${q}`);
1356
+ const goals = res.data?.goals ?? [];
1357
+ if (goals.length === 0) {
1358
+ return {
1359
+ content: [
1360
+ {
1361
+ type: "text",
1362
+ 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.`,
1363
+ },
1364
+ ],
1365
+ };
1366
+ }
1367
+ const pctFmt = (v) => v == null ? "—" : `${v > 0 ? "+" : ""}${(v * 100).toFixed(1)}%`;
1368
+ const lines = goals.map((g) => {
1369
+ if (!g.isDecisive) {
1370
+ return ` • ${g.goalName}: COLLECTING (directional ${pctFmt(g.lift)}, since ${g.collectingSince || "—"})`;
1371
+ }
1372
+ const ci = g.liftCI
1373
+ ? ` [${pctFmt(g.liftCI.lower)}, ${pctFmt(g.liftCI.upper)}]`
1374
+ : "";
1375
+ const rev = typeof g.incrementalRevenue === "number"
1376
+ ? ` · ~$${Math.round(g.incrementalRevenue).toLocaleString()} incremental`
1377
+ : "";
1378
+ return ` • ${g.goalName}: ${pctFmt(g.lift)} vs holdout${ci} — DECISIVE${rev}`;
1379
+ });
1380
+ return {
1381
+ content: [
1382
+ {
1383
+ type: "text",
1384
+ text: [
1385
+ `${APEX} Is our growth real? (vs ${res.data?.holdoutPct ?? 5}% do-nothing holdout)`,
1386
+ `${"═".repeat(40)}`,
1387
+ ...lines,
1388
+ ``,
1389
+ `Decisive = both cohorts cleared the minimum size AND the CI excludes zero. Everything else is directional, not proven.`,
1390
+ ].join("\n"),
1391
+ },
1392
+ ],
1393
+ };
1394
+ },
1395
+ },
1054
1396
  suggest_experiment: {
1055
1397
  description: `${APEX} — Get smart experiment suggestions based on context. Analyzes your assumptions, past experiments, and confidence gaps to recommend what to test next.`,
1056
1398
  schema: z.object({
@@ -2326,6 +2668,143 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2326
2668
  // surfaces shipped in `journey_exit_semantics_0b4fe496.plan.md`.
2327
2669
  // Agent-friendly arg names (snake_case) are mapped to the REST
2328
2670
  // routes inside each handler.
2671
+ create_journey: {
2672
+ 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.`,
2673
+ schema: z.object({ name: z.string().optional().describe("Journey name (default 'Untitled journey').") }),
2674
+ handler: async ({ name }) => {
2675
+ try {
2676
+ const j = await apiPost("/api/journeys/blank", { name });
2677
+ if (!tenantOk(j.workspaceKey))
2678
+ return tenantMismatch();
2679
+ return { content: [{ type: "text", text: `${APEX} Created draft journey "${j.name}" (id: ${j.id}). Next: set_journey_trigger, then add_journey_step.` }] };
2680
+ }
2681
+ catch (err) {
2682
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2683
+ }
2684
+ },
2685
+ },
2686
+ create_journey_from_template: {
2687
+ 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.`,
2688
+ schema: z.object({ templateId: z.string().describe("Template id from get_wiring_status (e.g. 'mobile-cart-recovery').") }),
2689
+ handler: async ({ templateId }) => {
2690
+ try {
2691
+ const j = await apiPost("/api/journeys/from-template", { templateId });
2692
+ if (!tenantOk(j.workspaceKey))
2693
+ return tenantMismatch();
2694
+ return { content: [{ type: "text", text: `${APEX} Instantiated "${j.name}" (id: ${j.id}) from template ${templateId}. Review its steps with get_journey, then publish_journey.` }] };
2695
+ }
2696
+ catch (err) {
2697
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2698
+ }
2699
+ },
2700
+ },
2701
+ set_journey_trigger: {
2702
+ 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).`,
2703
+ schema: z.object({
2704
+ journeyId: z.string(),
2705
+ triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on."),
2706
+ }),
2707
+ handler: async ({ journeyId, triggerContractId }) => {
2708
+ try {
2709
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
2710
+ const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
2711
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
2712
+ if (!tenantOk(updated.workspaceKey))
2713
+ return tenantMismatch();
2714
+ return { content: [{ type: "text", text: `${APEX} Trigger set to contract ${triggerContractId} on journey ${journeyId}.` }] };
2715
+ }
2716
+ catch (err) {
2717
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2718
+ }
2719
+ },
2720
+ },
2721
+ add_journey_step: {
2722
+ 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.`,
2723
+ schema: z.object({
2724
+ journeyId: z.string(),
2725
+ kind: z.enum(["wait", "send"]),
2726
+ durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
2727
+ commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
2728
+ commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
2729
+ channels: z.array(z.enum(["email", "in_app", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email'])."),
2730
+ }),
2731
+ handler: async (args) => {
2732
+ try {
2733
+ if (args.kind === "send" && !args.commId) {
2734
+ return { content: [{ type: "text", text: `${APEX} A send step needs commId.` }], isError: true };
2735
+ }
2736
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
2737
+ const steps = [...(j.steps ?? [])];
2738
+ const exit = steps.find((s) => s.type === "exit");
2739
+ if (!exit) {
2740
+ return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert.` }], isError: true };
2741
+ }
2742
+ const exitId = exit.id;
2743
+ const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
2744
+ const newStep = args.kind === "wait"
2745
+ ? { id: newId, type: "wait", label: "Wait", mode: "duration", duration: args.durationIso ?? "P1D", next: exitId }
2746
+ : { id: newId, type: "send", label: "Send", commId: args.commId, commVersion: args.commVersion ?? 1, channels: args.channels ?? ["email"], next: exitId };
2747
+ // Insert before the exit: rewire whatever currently points to exit.
2748
+ const pre = steps.find((s) => s.next === exitId);
2749
+ if (pre)
2750
+ pre.next = newId;
2751
+ steps.push(newStep);
2752
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
2753
+ if (!tenantOk(updated.workspaceKey))
2754
+ return tenantMismatch();
2755
+ return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step to journey ${args.journeyId}.` }] };
2756
+ }
2757
+ catch (err) {
2758
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2759
+ }
2760
+ },
2761
+ },
2762
+ publish_journey: {
2763
+ 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.`,
2764
+ schema: z.object({
2765
+ journeyId: z.string(),
2766
+ confirmLive: z.boolean().optional().describe("Set true to publish for real. Omitted/false = dry-run validation only."),
2767
+ }),
2768
+ handler: async ({ journeyId, confirmLive }) => {
2769
+ const path = `/api/journeys/${encodeURIComponent(journeyId)}/publish`;
2770
+ if (!confirmLive) {
2771
+ try {
2772
+ await apiPost(`${path}?dryRun=true`, {});
2773
+ 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.` }] };
2774
+ }
2775
+ catch (err) {
2776
+ return { content: [{ type: "text", text: `${APEX} Dry-run found a problem before publishing: ${errMsg(err)}` }], isError: true };
2777
+ }
2778
+ }
2779
+ try {
2780
+ const res = await apiPost(path, {});
2781
+ if (!tenantOk(res.workspaceKey))
2782
+ return tenantMismatch();
2783
+ return { content: [{ type: "text", text: `${APEX} Published journey ${journeyId} — it is now live on trigger events.` }] };
2784
+ }
2785
+ catch (err) {
2786
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2787
+ }
2788
+ },
2789
+ },
2790
+ create_communication: {
2791
+ 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).`,
2792
+ schema: z.object({
2793
+ title: z.string().optional(),
2794
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push"])).optional(),
2795
+ }),
2796
+ handler: async ({ title, channels }) => {
2797
+ try {
2798
+ const comm = await apiPost("/api/communications/blank", { title, channels });
2799
+ if (!tenantOk(comm.workspaceKey))
2800
+ return tenantMismatch();
2801
+ return { content: [{ type: "text", text: `${APEX} Created draft communication "${comm.title}" (id: ${comm.id}). Edit it with edit_communication, then reference it from a journey send step.` }] };
2802
+ }
2803
+ catch (err) {
2804
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
2805
+ }
2806
+ },
2807
+ },
2329
2808
  list_journeys: {
2330
2809
  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.`,
2331
2810
  schema: z.object({}),
@@ -2635,8 +3114,8 @@ _Suggest the next step the user should tackle based on what's incomplete in the
2635
3114
  const readyTemplates = d.templates.filter((t) => t.ready);
2636
3115
  const blockedTemplates = d.templates.filter((t) => !t.ready);
2637
3116
  const templateLines = [
2638
- ...readyTemplates.map((t) => `- ✅ ${t.title} (${t.templateKind}) — Ready to run`),
2639
- ...blockedTemplates.slice(0, 10).map((t) => `- ⏳ ${t.title} (${t.templateKind}) — needs ${[...t.missingEvents, ...t.missingTraits].join(", ") || "?"}`),
3117
+ ...readyTemplates.map((t) => `- ✅ ${t.title} (${t.templateKind}, id: ${t.templateId}) — Ready to run${t.templateKind === "journey" ? ` → create_journey_from_template({ templateId: "${t.templateId}" })` : ""}`),
3118
+ ...blockedTemplates.slice(0, 10).map((t) => `- ⏳ ${t.title} (${t.templateKind}, id: ${t.templateId}) — needs ${[...t.missingEvents, ...t.missingTraits].join(", ") || "?"}`),
2640
3119
  ];
2641
3120
  const milestones = [
2642
3121
  d.milestones.firstEventAt ? `First event: ${d.milestones.firstEventAt}` : "First event: not yet",