@apex-inc/mcp-server 0.26.0 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -2,10 +2,13 @@ import { z } from "zod";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { apiGet, apiPost, apiPatch, apiPut, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
5
+ import { collectHosts, evaluateTargetUrl } from "./workspace-target-url.js";
5
6
  import { resolveExperimentHypothesis } from "./experiment-copy.js";
6
7
  import { controlGateGuidanceFor } from "./control-gate-guidance.js";
7
8
  import { getPersonLabel, lowerPerson } from "./person-label.js";
8
- const APEX = "∧ Apex";
9
+ import { notStartedExplanation, publishedExperimentLine, } from "./publish-communication-copy.js";
10
+ import { activationLiveLine, shouldRefuseActivationWiring, } from "./activation-wiring.js";
11
+ const APEX = "Apex";
9
12
  /**
10
13
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
11
14
  *
@@ -55,6 +58,55 @@ function titleCaseEvent(event) {
55
58
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
56
59
  .join(" ");
57
60
  }
61
+ function activeWorkspaceSiteUrl() {
62
+ const ctx = getUserContext();
63
+ const key = getActiveWorkspace();
64
+ if (!ctx || !key)
65
+ return null;
66
+ for (const org of ctx.orgs) {
67
+ const match = org.workspaces.find((w) => w.workspaceKey === key);
68
+ if (match?.url)
69
+ return match.url;
70
+ }
71
+ return null;
72
+ }
73
+ async function assertTargetUrlForActiveWorkspace(targetUrl) {
74
+ const namedSites = [];
75
+ let workspaceUrl = activeWorkspaceSiteUrl();
76
+ let catalogUnreadable = false;
77
+ try {
78
+ const env = await apiGet("/api/workspace/environments");
79
+ namedSites.push(...(env.data.production_hosts ?? []), ...(env.data.beta_hosts ?? []));
80
+ for (const row of env.data.known_hosts ?? []) {
81
+ namedSites.push(typeof row === "string" ? row : row.hostname ?? "");
82
+ }
83
+ if (env.data.workspace_url)
84
+ workspaceUrl = env.data.workspace_url;
85
+ }
86
+ catch {
87
+ catalogUnreadable = true;
88
+ }
89
+ let analyticsHosts = [];
90
+ try {
91
+ const json = await apiGet("/api/analytics/hostnames?limit=100");
92
+ analyticsHosts = json.data ?? [];
93
+ }
94
+ catch {
95
+ /* analytics is a supplement, not the source of truth */
96
+ }
97
+ const allowed = collectHosts({
98
+ analyticsHosts,
99
+ namedSites,
100
+ workspaceUrl,
101
+ });
102
+ if (allowed.length === 0 && catalogUnreadable) {
103
+ return {
104
+ ok: false,
105
+ message: "Could not load this workspace's sites (list_workspace_environments failed). Name the site with set_workspace_environments before setting target_url. Do not guess a brand domain or a sibling workspace's site.",
106
+ };
107
+ }
108
+ return evaluateTargetUrl(targetUrl, allowed);
109
+ }
58
110
  /**
59
111
  * Query string for the pack report windows: relative (`days` / `months`)
60
112
  * or explicit `start` + `end` (YYYY-MM-DD, both required together —
@@ -81,28 +133,6 @@ function appUrl(path) {
81
133
  const base = process.env.APEX_URL || process.env.APEX_API_URL || "http://localhost:3001";
82
134
  return `${base.replace(/\/$/, "")}${path}`;
83
135
  }
84
- /**
85
- * Why publishing a comm with variants didn't start an experiment.
86
- *
87
- * Each of these is a missing precondition the user can actually fix, so the
88
- * agent is told what to do rather than that "nothing happened."
89
- */
90
- function notStartedExplanation(reason) {
91
- switch (reason) {
92
- case "no_host":
93
- return "The variants were published but no experiment started: no published journey sends this communication, so there's no traffic to measure. Add it to a journey and publish that journey.";
94
- case "no_goal":
95
- return "The variants were published but no experiment started: the journey sending this communication has no goal event, so there's nothing to optimize toward. Set the journey's goal event first.";
96
- case "ambiguous_host":
97
- return "The variants were published but no experiment started: more than one published journey sends this communication. Re-issue publish_communication with host_journey_id.";
98
- case "conflict":
99
- return "The variants were published but no experiment started: another experiment already holds this communication. End it, then publish again.";
100
- case "no_variants":
101
- return "";
102
- default:
103
- return "The variants were published but the experiment could not be started. Check the communication in the dashboard.";
104
- }
105
- }
106
136
  function journeyLink(id) {
107
137
  return appUrl(`/dashboard/communications/journeys/${id}`);
108
138
  }
@@ -460,7 +490,7 @@ export const toolDefinitions = {
460
490
  description: `${APEX} — Create an experiment record. Returns a structured recipe for the AI agent to implement. The agent makes the actual code changes using useApexVariant hook — Apex never generates code. Always call with preview=true first so the user sees a summary.`,
461
491
  schema: z.object({
462
492
  name: z.string().describe("Experiment name"),
463
- targetUrl: z.string().describe("URL of the page to test (include #anchor for section targeting)"),
493
+ targetUrl: z.string().describe("URL of the page to test on the ACTIVE workspace (include #anchor for section targeting). Must be a host this workspace has actually sent events from — never guess a brand domain or a sibling workspace's site."),
464
494
  controlContent: z.string().describe("The current content that the control shows"),
465
495
  variantContent: z.string().describe("The new content for the variant"),
466
496
  targetComponent: z.string().optional().describe("Component or file path where the change should be made"),
@@ -487,6 +517,13 @@ export const toolDefinitions = {
487
517
  preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
488
518
  }),
489
519
  handler: async (args) => {
520
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
521
+ if (!targetCheck.ok) {
522
+ return {
523
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
524
+ isError: true,
525
+ };
526
+ }
490
527
  const split = args.trafficSplit ?? 50;
491
528
  // MCP experiments are ALWAYS code-native (SDK). Client-side snippet /
492
529
  // runtime-DOM experiments are intentionally NOT offered here (founder
@@ -811,12 +848,16 @@ export const toolDefinitions = {
811
848
  `2. Install the hook package: npm i @apex-inc/react`,
812
849
  `3. Add: import { useApexVariant } from "@apex-inc/react"`,
813
850
  `4. In the component, add: const variant = useApexVariant("${exp.id}") // fires experiment_exposure automatically`,
851
+ ` Next.js App Router: pass the request search string so the first HTML matches the preview URL: useApexVariant("${exp.id}", { search }). Without it the server renders control and the arm only appears after hydrate.`,
814
852
  `5. Wrap the target content in a conditional (fall back to control on null/error):`,
815
853
  ` {variant === "variant_a" || variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
816
- `6. Screenshots for the dashboard:`,
817
- ` - Public web page: Apex auto-captures on create nothing to do.`,
818
- ` - localhost / auth-gated: capture BOTH arms, then call attach_experiment_asset({experimentId, variantKey, imageBase64}).`,
819
- `7. Show the user the diff and ask them to preview at: ${previewUrl}`,
854
+ `6. Screenshots for the dashboard — REQUIRED before you tell the user the draft is ready:`,
855
+ ` - This is an SDK experiment. The variant exists only in local code until it ships.`,
856
+ ` - Apex does NOT auto-capture a public URL on create (that page does not have the variant yet, and a guessed host shows the wrong site on both arms).`,
857
+ ` - Capture BOTH arms on localhost with ?_apex_preview=control|variant_a&_apex_exp=${exp.id}, then call attach_experiment_asset({experimentId, variantKey, imageBase64}) for each arm.`,
858
+ ` - After the code is deployed, recapture_experiment_screenshots refreshes the live host.`,
859
+ `7. Show the user the diff. Dashboard "View live" links are built from target_url (not a hardcoded host). Preview the variant at: ${previewUrl}`,
860
+ ` If the merchant is QA'ing on localhost, the dashboard rewrites Apex app hosts to this origin so the unpublished variant is visible.`,
820
861
  `8. After preview approval, commit and push`,
821
862
  `9. Call track_deployment with the experiment ID and commit SHA`,
822
863
  `10. Call activate_experiment (with the user's go-ahead). If the code isn't live yet, the`,
@@ -960,7 +1001,7 @@ export const toolDefinitions = {
960
1001
  },
961
1002
  },
962
1003
  recapture_experiment_screenshots: {
963
- description: `${APEX} — Re-run the hosted screenshot capture for a WEB experiment's arms (renders the target URL for control + variant and stores the images shown on the dashboard). Use when screenshots are missing or stale e.g. the page changed, or an earlier capture failed. Web experiments only (mobile arms capture on-device via the SDK).`,
1004
+ description: `${APEX} — Re-run the hosted screenshot capture for a WEB experiment's arms (renders the target URL for control + variant and stores the images shown on the dashboard). Use after the variant is deployed. For unpublished SDK / Cursor drafts, attach localhost shots with attach_experiment_asset instead hosted recapture of a public URL will be refused.`,
964
1005
  schema: z.object({
965
1006
  experimentId: z.string().describe("The experiment ID to re-capture screenshots for."),
966
1007
  }),
@@ -1066,7 +1107,7 @@ export const toolDefinitions = {
1066
1107
  },
1067
1108
  },
1068
1109
  activate_experiment: {
1069
- description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Before flipping to running, this verifies BOTH arms are producing \`experiment_exposure\` events (wiring check). Code-wired (SDK-hook) experiments may be activated before their code ships: the server holds them in \`pending_deployment\` ("Waiting on deploy") and starts on the first exposure. Snippet experiments are applied by the Apex snippet at runtime, so they also don't need pre-activation exposures — they launch and wiring is verified afterward. Only wired experiments that genuinely can't self-start are refused unless force is true. Ask the user to confirm before calling.`,
1110
+ description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Before flipping to running, this verifies BOTH arms are producing \`experiment_exposure\` events (wiring check). Code-wired (SDK-hook) experiments may be activated before their code ships: the server holds them in \`pending_deployment\` ("Waiting on deploy") and starts on the first exposure. Snippet experiments and journey_arm message tests do not need pre-activation exposures — journey traffic starts on the next send once running. Only wired web experiments that genuinely can't self-start are refused unless force is true. Ask the user to confirm before calling.`,
1070
1111
  schema: z.object({
1071
1112
  experimentId: z.string().describe("The experiment ID to activate"),
1072
1113
  force: z
@@ -1091,11 +1132,7 @@ export const toolDefinitions = {
1091
1132
  // the experiment ONCE IT'S RUNNING, so it can't have pre-activation
1092
1133
  // exposures — blocking on them is a chicken-and-egg. Both are safe to
1093
1134
  // launch "early"; wiring is verified AFTER via verify_experiment_wiring.
1094
- if (wiring &&
1095
- !wiring.bothArmsLive &&
1096
- force !== true &&
1097
- !wiring.sdkMode &&
1098
- !wiring.snippetMode) {
1135
+ if (wiring && shouldRefuseActivationWiring(wiring, force)) {
1099
1136
  const armLines = wiring.perArm.map(formatArmLine);
1100
1137
  const zeroArms = wiring.perArm
1101
1138
  .filter((a) => a.exposures === 0)
@@ -1194,9 +1231,11 @@ export const toolDefinitions = {
1194
1231
  // PATCH returns a minimal/legacy body — re-read the unified record to
1195
1232
  // derive the live traffic split from the allocation weights.
1196
1233
  let controlPct = 50;
1234
+ let liveSurface = exp.surface;
1197
1235
  try {
1198
1236
  const unified = await apiGet(`/api/experiments/${experimentId}?unified=true`);
1199
1237
  controlPct = controlPctOf(unified);
1238
+ liveSurface = unified.surface ?? liveSurface;
1200
1239
  }
1201
1240
  catch { /* fall back to 50/50 in the message */ }
1202
1241
  // Surface why the wiring gate didn't block: either it couldn't be
@@ -1235,7 +1274,7 @@ export const toolDefinitions = {
1235
1274
  ` Traffic is being split ${controlPct}% / ${100 - controlPct}%`,
1236
1275
  ``,
1237
1276
  ...notes,
1238
- ` Visitors will now see either the control or variant.`,
1277
+ ` ${activationLiveLine(liveSurface)}`,
1239
1278
  ` Results will appear in the dashboard and via get_results.`,
1240
1279
  ``,
1241
1280
  `${"═".repeat(40)}`,
@@ -1646,6 +1685,8 @@ export const toolDefinitions = {
1646
1685
  name: z.string().optional().describe("Rename (cosmetic; editable anytime)."),
1647
1686
  surface: z.enum(["web", "mobile"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile)."),
1648
1687
  hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
1688
+ targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
1689
+ predictionId: z.string().optional().describe("Attach a minted prediction id (from log_prediction) to this draft. Required before activation."),
1649
1690
  primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
1650
1691
  primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
1651
1692
  guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
@@ -1653,6 +1694,15 @@ export const toolDefinitions = {
1653
1694
  runtimeDays: z.number().int().min(1).max(365).optional().describe("Set how long to run the experiment, in days (the decision window Apex force-concludes at). Draft only — part of the pre-registration; freezes at launch (fork to change it)."),
1654
1695
  }),
1655
1696
  handler: async (args) => {
1697
+ if (args.targetUrl) {
1698
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
1699
+ if (!targetCheck.ok) {
1700
+ return {
1701
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
1702
+ isError: true,
1703
+ };
1704
+ }
1705
+ }
1656
1706
  const body = { id: args.experimentId };
1657
1707
  if (args.name !== undefined)
1658
1708
  body.name = args.name;
@@ -1660,6 +1710,10 @@ export const toolDefinitions = {
1660
1710
  body.surface = args.surface;
1661
1711
  if (args.hypothesis !== undefined)
1662
1712
  body.hypothesis = args.hypothesis;
1713
+ if (args.targetUrl !== undefined)
1714
+ body.targetUrl = args.targetUrl;
1715
+ if (args.predictionId !== undefined)
1716
+ body.predictionId = args.predictionId;
1663
1717
  // evaluationWindow patch — the API merges it, so setting one of
1664
1718
  // minPerVariant / durationDays never clobbers the other.
1665
1719
  if (args.minSamplePerArm !== undefined || args.runtimeDays !== undefined) {
@@ -1746,7 +1800,15 @@ export const toolDefinitions = {
1746
1800
  content: [
1747
1801
  {
1748
1802
  type: "text",
1749
- text: `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}). Edit it with update_experiment, then activate.`,
1803
+ text: [
1804
+ `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}).`,
1805
+ ``,
1806
+ `A copy gets a new id. The page still asks the old one until you replace it.`,
1807
+ ` Find: const variant = useApexVariant("${experimentId}");`,
1808
+ ` Replace: const variant = useApexVariant("${forked.id}");`,
1809
+ `Deploy that change, then activate.`,
1810
+ `Preview still works via _apex_exp. A normal visit shows control until the new id is shipped.`,
1811
+ ].join("\n"),
1750
1812
  },
1751
1813
  ],
1752
1814
  };
@@ -2742,9 +2804,9 @@ Identical to the SDK's track() and the snippet's apex.track(). Use this to instr
2742
2804
 
2743
2805
  The Apex Spec ships two peer registries:
2744
2806
 
2745
- \u2022 **App events** (\`APEX_EVENTS\`) — the canonical SDK vocabulary the customer's app fires. Names you'll commonly use here: \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`in_app_purchase\`, \`purchase_refunded\`, \`subscription_event\`, \`user_signed_up\`, \`user_signed_in\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`click\`, \`search\`, \`share\`, \`content_view\`, \`goal_conversion\`, \`email_opened\`, \`email_clicked\`, \`push_opened\`, \`in_app_message_seen\`, \`deep_link_open\`. Field names are flat snake_case (e.g. \`product_id\`, \`order_id\`, \`value\`, \`currency\`).
2807
+ \u2022 **App events** (\`APEX_EVENTS\`) — the canonical SDK vocabulary the customer's app fires. Names you'll commonly use here: \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`in_app_purchase\`, \`purchase_refunded\`, \`subscription_event\`, \`user_signed_up\`, \`user_signed_in\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`click\`, \`ui_action\`, \`search\`, \`share\`, \`content_view\`, \`goal_conversion\`, \`email_opened\`, \`email_clicked\`, \`push_opened\`, \`in_app_message_seen\`, \`deep_link_open\`. Field names are flat snake_case (e.g. \`product_id\`, \`order_id\`, \`value\`, \`currency\`). Named in-product controls (buttons, tabs, widget chrome) are \`ui_action\` with \`action\` + \`name\` + optional \`location\` — do not invent one event per button. SDK helper: \`trackUiAction({ action, name, location })\`.
2746
2808
 
2747
- \u2022 **Platform events** (\`APEX_PLATFORM_EVENTS\`) — events Apex's own dashboard fires as merchants use it. Names: \`user_invitation_created\`, \`user_invitation_accepted\`, \`developer_invite_sent\`, \`integration_connected\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`experiment_launched\`, \`experiment_significant\`, \`experiment_concluded\`, \`experiment_promoted\`, \`portfolio_access_requested\`, \`portfolio_access_approved\`, \`portfolio_access_rejected\`, \`portfolio_share_accepted\`, \`subscription_upgraded\`, \`subscription_downgraded\`, \`tier_limit_approaching\`, \`anomaly_detected\`, \`weekly_digest_scheduled\`, \`cognito_verification_code\`, \`user_signup\`. Use these when an agent is acting *as* the Apex platform (admin tooling, dashboard automation), not as the customer's product.
2809
+ \u2022 **Platform events** (\`APEX_PLATFORM_EVENTS\`) — events Apex's own dashboard fires as merchants use it. Names: \`user_invitation_created\`, \`user_invitation_accepted\`, \`developer_invite_sent\`, \`integration_selected\`, \`integration_connected\`, \`setup_step_continued\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`experiment_launched\`, \`experiment_significant\`, \`experiment_concluded\`, \`experiment_promoted\`, \`portfolio_access_requested\`, \`portfolio_access_approved\`, \`portfolio_access_rejected\`, \`portfolio_share_accepted\`, \`subscription_upgraded\`, \`subscription_downgraded\`, \`tier_limit_approaching\`, \`anomaly_detected\`, \`weekly_digest_scheduled\`, \`cognito_verification_code\`, \`user_signup\`. Use these when an agent is acting *as* the Apex platform (admin tooling, dashboard automation), not as the customer's product.
2748
2810
 
2749
2811
  Custom event names are accepted (the spec is open by default), but agents should prefer canonical names from one of the two registries so the event flows directly into the catalog templates, the journey trigger system, and the conversion-goal helpers without manual wiring. The full spec lives at https://docs.apex.inc/spec.
2750
2812
 
@@ -3114,7 +3176,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3114
3176
  },
3115
3177
  },
3116
3178
  list_communications: {
3117
- 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, edit freely).",
3179
+ 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.",
3118
3180
  schema: z.object({
3119
3181
  pipeline: z
3120
3182
  .enum(["transactional", "marketing"])
@@ -3341,13 +3403,13 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3341
3403
  },
3342
3404
  },
3343
3405
  publish_communication: {
3344
- description: "Publish a communication's draft as the live version — this is what makes an edit reach recipients. Editing only saves a draft.\n\nIf the communication has variants, publishing STARTS the experiment that measures them against the control. That is the point of authoring a variant: a variant that is never published is measured by nothing.\n\nTwo conflicts need a decision rather than a retry. `ambiguous_experiment_host` means several published journeys send this communication, so Apex cannot tell where the experiment belongs — 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.",
3406
+ 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.",
3345
3407
  schema: z.object({
3346
3408
  communicationId: z.string().describe("The communication ID to publish"),
3347
3409
  intent: z
3348
3410
  .enum(["compete", "replace"])
3349
3411
  .optional()
3350
- .describe('How to apply the draft when the Control is protected. "compete" = the current content keeps sending and your draft runs against it as an experiment (safe default). "replace" = your draft becomes what everyone receives, superseding any prior win. Required only when the comm won an experiment or is live in a published journey.'),
3412
+ .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.'),
3351
3413
  confirmLiveExperiment: z
3352
3414
  .boolean()
3353
3415
  .optional()
@@ -3356,6 +3418,22 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3356
3418
  .string()
3357
3419
  .optional()
3358
3420
  .describe("Which published journey hosts the experiment. Only needed after a 409 ambiguous_experiment_host, using one of the returned candidate ids."),
3421
+ hypothesis: z
3422
+ .string()
3423
+ .optional()
3424
+ .describe("Override the Suggested hypothesis on the draft experiment."),
3425
+ beliefId: z
3426
+ .string()
3427
+ .optional()
3428
+ .describe("Link the draft experiment to an existing belief."),
3429
+ confidence: z
3430
+ .number()
3431
+ .optional()
3432
+ .describe("Override the prefilled confidence (percent)."),
3433
+ durationDays: z
3434
+ .number()
3435
+ .optional()
3436
+ .describe("Override the prefilled decision window in days."),
3359
3437
  }),
3360
3438
  handler: async (args) => {
3361
3439
  const body = {};
@@ -3365,6 +3443,14 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3365
3443
  body.confirmLiveExperiment = true;
3366
3444
  if (args.hostJourneyId)
3367
3445
  body.hostJourneyId = args.hostJourneyId;
3446
+ if (args.hypothesis)
3447
+ body.hypothesis = args.hypothesis;
3448
+ if (args.beliefId)
3449
+ body.beliefId = args.beliefId;
3450
+ if (typeof args.confidence === "number")
3451
+ body.confidence = args.confidence;
3452
+ if (typeof args.durationDays === "number")
3453
+ body.durationDays = args.durationDays;
3368
3454
  try {
3369
3455
  const data = await apiPost(`/api/communications/${args.communicationId}/publish`, body);
3370
3456
  const lines = [];
@@ -3378,7 +3464,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3378
3464
  lines.push(`${data.repinned.length} send step(s) moved to the new version.`);
3379
3465
  }
3380
3466
  if (data.experiment) {
3381
- lines.push(`Experiment started: ${data.experiment.id}. Watch it at ${appUrl(`/dashboard/experiments/${data.experiment.id}`)}`);
3467
+ lines.push(publishedExperimentLine({
3468
+ id: data.experiment.id,
3469
+ status: data.experiment.status ?? "draft",
3470
+ url: appUrl(`/dashboard/experiments/${data.experiment.id}`),
3471
+ }));
3382
3472
  }
3383
3473
  else if (data.experimentNotStarted) {
3384
3474
  lines.push(notStartedExplanation(data.experimentNotStarted));
@@ -3453,7 +3543,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3453
3543
  },
3454
3544
  },
3455
3545
  add_communication_variant: {
3456
- 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. Does not change what recipients get until you publish. Theme look is inherited; do not set per-block align here.",
3546
+ 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.",
3457
3547
  schema: z.object({
3458
3548
  communicationId: z.string().describe("The communication to add a variant to"),
3459
3549
  label: z.string().optional().describe("Optional column label (e.g. Variant A)"),
@@ -3496,7 +3586,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3496
3586
  },
3497
3587
  },
3498
3588
  create_comm_experiment: {
3499
- description: "A/B test a communication's subject, body, or CTA. Creates an experiment with variants that split traffic using Thompson Sampling.",
3589
+ 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.",
3500
3590
  schema: z.object({
3501
3591
  communicationId: z.string().describe("The communication to experiment on"),
3502
3592
  splitLevel: z.enum(["subject", "body", "cta", "full"]).describe("What to vary between variants"),