@apex-inc/mcp-server 0.9.3 → 0.9.8

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
@@ -11,6 +11,107 @@ const APEX = "∧ Apex";
11
11
  * separate agent sessions from each other.
12
12
  */
13
13
  const MCP_AGENT_VISITOR_ID = `mcp-agent-${Math.random().toString(36).slice(2, 10)}`;
14
+ /** Control traffic % derived from the unified allocation weights (default 50). */
15
+ function controlPctOf(exp) {
16
+ const w = exp.allocation?.weights?.control;
17
+ return typeof w === "number" ? Math.round(w * 100) : 50;
18
+ }
19
+ /**
20
+ * Days running, derived locally from startedAt/createdAt → completedAt/now.
21
+ * The legacy web shape carried a server-computed `daysRunning`; the unified
22
+ * shape does not, so we compute it from the lifecycle timestamps.
23
+ */
24
+ function daysRunningOf(exp) {
25
+ const start = exp.startedAt || exp.createdAt;
26
+ if (!start)
27
+ return 0;
28
+ const startMs = new Date(start).getTime();
29
+ if (Number.isNaN(startMs))
30
+ return 0;
31
+ const endMs = exp.completedAt ? new Date(exp.completedAt).getTime() : Date.now();
32
+ return Math.max(0, Math.floor((endMs - startMs) / 86_400_000));
33
+ }
34
+ /** Find a unified variant by its key (e.g. "control", "variant_b"). */
35
+ function findVariant(exp, key) {
36
+ return exp.variants?.find((v) => v.key === key);
37
+ }
38
+ /** Render a per-arm exposure line for wiring summaries. */
39
+ function formatArmLine(arm) {
40
+ const live = arm.exposures > 0;
41
+ const dot = live ? "●" : "○";
42
+ const seen = arm.lastSeenAt ? `, last seen ${arm.lastSeenAt}` : "";
43
+ return ` ${dot} ${arm.label} (${arm.variantKey}): ${arm.exposures} exposure${arm.exposures === 1 ? "" : "s"}${seen}`;
44
+ }
45
+ /** "add_to_cart" → "Add To Cart" (for primaryMetric labels). */
46
+ function titleCaseEvent(event) {
47
+ return event
48
+ .split(/[_\s]+/)
49
+ .filter(Boolean)
50
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
51
+ .join(" ");
52
+ }
53
+ /**
54
+ * Shared schema + handler for `switch_workspace` (canonical) and its
55
+ * deprecated alias `switch_project`. Both register against the same handler
56
+ * so existing agent calls keep working through the project→workspace rename.
57
+ *
58
+ * After updating the active workspace locally, we VALIDATE the switch with an
59
+ * authed read against `/api/setup-state` (which `apiGet` scopes to the active
60
+ * workspace). Validation is non-fatal: the local switch always succeeds, but
61
+ * we surface an auth/reachability warning so the user knows to connect or
62
+ * fix the workspace key.
63
+ */
64
+ const switchWorkspaceSchema = z.object({
65
+ workspaceKey: z.string().optional().describe("The workspace key to switch to."),
66
+ projectKey: z
67
+ .string()
68
+ .optional()
69
+ .describe("Deprecated alias of workspaceKey. Use workspaceKey."),
70
+ });
71
+ async function switchWorkspaceHandler(args) {
72
+ const key = args.workspaceKey ?? args.projectKey;
73
+ if (!key) {
74
+ return {
75
+ content: [{
76
+ type: "text",
77
+ text: "Provide a workspaceKey to switch to (projectKey is accepted as a deprecated alias).",
78
+ }],
79
+ isError: true,
80
+ };
81
+ }
82
+ setActiveWorkspace(key);
83
+ const deprecationNote = !args.workspaceKey && args.projectKey
84
+ ? " (note: `projectKey` is deprecated — use `workspaceKey`.)"
85
+ : "";
86
+ // Validate the switch by reading setup-state for the now-active workspace.
87
+ let validation = " Verified — Apex can read this workspace.";
88
+ try {
89
+ await apiGet("/api/setup-state");
90
+ }
91
+ catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ if (/\(401\)|\(403\)|authentication failed/i.test(message)) {
94
+ validation =
95
+ "\n\n⚠️ Switched locally, but could not verify the workspace — authentication failed. " +
96
+ "Connect the MCP server to Apex (run any Apex tool to get a one-time approval link), " +
97
+ "or regenerate APEX_API_KEY in Apex → Settings → AI Agents, then try again.";
98
+ }
99
+ else if (/\(404\)/.test(message)) {
100
+ validation =
101
+ `\n\n⚠️ Switched locally, but Apex returned 404 for "${key}" — the workspace key may be wrong. ` +
102
+ "Use list_projects to see the valid keys for your API key.";
103
+ }
104
+ else {
105
+ validation = `\n\n⚠️ Switched locally, but could not reach Apex to verify it (${message}).`;
106
+ }
107
+ }
108
+ return {
109
+ content: [{
110
+ type: "text",
111
+ text: `Switched to workspace: ${key}. All subsequent calls will use this workspace.${deprecationNote}${validation}`,
112
+ }],
113
+ };
114
+ }
14
115
  export const toolDefinitions = {
15
116
  plan_experiment: {
16
117
  description: `${APEX} — PLANNING ONLY, does not create anything. Analyzes a goal against existing beliefs and experiments, then returns structured options for the user to choose from. After calling, you MUST present the returned options as a numbered list or poll and wait for the user to pick before calling any other Apex tools.`,
@@ -21,7 +122,7 @@ export const toolDefinitions = {
21
122
  handler: async ({ goal, targetUrl }) => {
22
123
  const [beliefs, experiments] = await Promise.all([
23
124
  apiGet("/api/beliefs"),
24
- apiGet("/api/experiments"),
125
+ apiGet("/api/experiments?unified=true"),
25
126
  ]);
26
127
  const keywords = goal
27
128
  .toLowerCase()
@@ -167,6 +268,10 @@ export const toolDefinitions = {
167
268
  beliefId: z.string().optional().describe("ID of the assumption this tests"),
168
269
  hypothesisId: z.string().optional().describe("ID of the expected outcome"),
169
270
  predictionId: z.string().optional().describe("ID of the expected impact"),
271
+ 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."),
272
+ beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
273
+ hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
274
+ 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."),
170
275
  mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
171
276
  preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
172
277
  }),
@@ -174,6 +279,53 @@ export const toolDefinitions = {
174
279
  const split = args.trafficSplit ?? 50;
175
280
  const mode = args.mode ?? "sdk";
176
281
  const isPreview = args.preview !== false;
282
+ // p1-metric — resolve the primary metric. Defaults to the form_submit
283
+ // conversion metric; an explicit primaryMetricEvent is validated against
284
+ // the workspace's canonical event spec before we accept it.
285
+ let primaryMetric = {
286
+ key: "conversion_rate",
287
+ label: "Conversion rate",
288
+ type: "rate",
289
+ unit: "%",
290
+ direction: "increase",
291
+ source: { kind: "event", eventType: "form_submit" },
292
+ };
293
+ if (args.primaryMetricEvent) {
294
+ const event = args.primaryMetricEvent.trim();
295
+ let validEvents = [];
296
+ try {
297
+ const spec = await apiGet("/api/spec/events");
298
+ validEvents = Object.keys(spec.data?.events ?? {});
299
+ }
300
+ catch (err) {
301
+ const message = err instanceof Error ? err.message : String(err);
302
+ return {
303
+ content: [{
304
+ type: "text",
305
+ text: `Could not load the event spec to validate primaryMetricEvent "${event}" (${message}). Fix the connection or omit primaryMetricEvent to use the default conversion metric.`,
306
+ }],
307
+ isError: true,
308
+ };
309
+ }
310
+ if (validEvents.length && !validEvents.includes(event)) {
311
+ const sample = validEvents.slice(0, 12).join(", ");
312
+ return {
313
+ content: [{
314
+ type: "text",
315
+ text: `"${event}" is not a canonical event in this workspace's event spec. Valid options include: ${sample}${validEvents.length > 12 ? ", …" : ""}. Use get_event_spec for the full list.`,
316
+ }],
317
+ isError: true,
318
+ };
319
+ }
320
+ primaryMetric = {
321
+ key: `${event}_rate`,
322
+ label: `${titleCaseEvent(event)} rate`,
323
+ type: "rate",
324
+ unit: "%",
325
+ direction: "increase",
326
+ source: { kind: "event", eventType: event },
327
+ };
328
+ }
177
329
  if (isPreview) {
178
330
  const recipe = {
179
331
  _apex: true,
@@ -186,25 +338,74 @@ export const toolDefinitions = {
186
338
  trafficSplit: { control: split, variant: 100 - split },
187
339
  control: args.controlContent,
188
340
  variant: args.variantContent,
341
+ primaryMetric: primaryMetric.key,
189
342
  beliefId: args.beliefId || null,
343
+ beliefStatement: args.beliefStatement || null,
190
344
  predictionId: args.predictionId || null,
191
345
  _instructions: "Present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.",
192
346
  };
193
347
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
194
348
  }
349
+ // p2-linking — if the caller passed a belief statement instead of a
350
+ // pre-created beliefId, create the belief + hypothesis (mirroring
351
+ // start_reasoning) and link them to this experiment.
352
+ let beliefId = args.beliefId;
353
+ let hypothesisId = args.hypothesisId;
354
+ if (!beliefId && args.beliefStatement) {
355
+ const confidence = args.beliefConfidence != null ? args.beliefConfidence / 100 : 0.5;
356
+ const b = await apiPost("/api/beliefs", {
357
+ statement: args.beliefStatement,
358
+ confidence,
359
+ });
360
+ beliefId = b.id;
361
+ const hypothesisStatement = args.hypothesisStatement ??
362
+ `If we act on "${args.beliefStatement.slice(0, 80)}", then we should see a measurable improvement in "${args.name}".`;
363
+ const h = await apiPost("/api/hypotheses", {
364
+ beliefId: b.id,
365
+ statement: hypothesisStatement,
366
+ predictedEffect: `Impact related to: ${args.name.slice(0, 60)}`,
367
+ confidence,
368
+ status: "active",
369
+ });
370
+ hypothesisId = h.id;
371
+ }
372
+ const controlW = split / 100;
195
373
  const exp = await apiPost("/api/experiments", {
374
+ surface: "web",
196
375
  name: args.name,
197
376
  targetUrl: args.targetUrl,
198
- trafficSplit: split,
199
- beliefId: args.beliefId,
200
- hypothesisId: args.hypothesisId,
201
- predictionId: args.predictionId,
202
- mode,
203
377
  targetAnchor: args.targetAnchor,
204
- variants: {
205
- control: { name: "Control", description: "Original content", changes: [] },
206
- variant_b: {
207
- name: "Variant B",
378
+ createdFrom: "cursor",
379
+ status: "draft",
380
+ hypothesis: "",
381
+ prediction: { direction: "increase", magnitude: "" },
382
+ confidence: 50,
383
+ beliefId,
384
+ hypothesisId,
385
+ predictionId: args.predictionId,
386
+ primaryMetric,
387
+ allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
388
+ secondaryMetrics: [],
389
+ guardrailMetrics: [],
390
+ attributionWindow: { unit: "hours", value: 24, startFrom: "first_interaction" },
391
+ variants: [
392
+ {
393
+ id: "v_control",
394
+ key: "control",
395
+ label: "Control",
396
+ surface: "web",
397
+ mode,
398
+ createdAt: new Date().toISOString(),
399
+ description: "Original content",
400
+ changes: [],
401
+ },
402
+ {
403
+ id: "v_b",
404
+ key: "variant_b",
405
+ label: "Variant B",
406
+ surface: "web",
407
+ mode,
408
+ createdAt: new Date().toISOString(),
208
409
  description: "Modified content",
209
410
  changes: [
210
411
  {
@@ -215,7 +416,7 @@ export const toolDefinitions = {
215
416
  },
216
417
  ],
217
418
  },
218
- },
419
+ ],
219
420
  });
220
421
  let previewUrl = "";
221
422
  try {
@@ -234,9 +435,14 @@ export const toolDefinitions = {
234
435
  name: exp.name,
235
436
  status: exp.status,
236
437
  mode,
438
+ primaryMetric: primaryMetric.key,
439
+ beliefId: beliefId || null,
440
+ hypothesisId: hypothesisId || null,
441
+ predictionId: args.predictionId || null,
237
442
  sdk: mode === "sdk" ? {
238
443
  hook: "useApexVariant",
239
- import: "@/lib/apex-sdk",
444
+ import: "@apex-inc/react",
445
+ install: "npm i @apex-inc/react",
240
446
  experimentId: exp.id,
241
447
  targetComponent: args.targetComponent || null,
242
448
  targetAnchor: args.targetAnchor || null,
@@ -249,14 +455,15 @@ export const toolDefinitions = {
249
455
  ? [
250
456
  "IMPLEMENT THE EXPERIMENT IN CODE:",
251
457
  `1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
252
- `2. Add: import { useApexVariant } from "@/lib/apex-sdk"`,
253
- `3. In the component, add: const variant = useApexVariant("${exp.id}")`,
254
- `4. Wrap the target content in a conditional:`,
458
+ `2. Install the hook package: npm i @apex-inc/react`,
459
+ `3. Add: import { useApexVariant } from "@apex-inc/react"`,
460
+ `4. In the component, add: const variant = useApexVariant("${exp.id}")`,
461
+ `5. Wrap the target content in a conditional:`,
255
462
  ` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
256
- `5. Show the user the diff and ask them to preview at: ${previewUrl}`,
257
- `6. After preview approval, commit and push`,
258
- `7. Call track_deployment with the experiment ID and commit SHA`,
259
- `8. Call activate_experiment after deployment confirms`,
463
+ `6. Show the user the diff and ask them to preview at: ${previewUrl}`,
464
+ `7. After preview approval, commit and push`,
465
+ `8. Call track_deployment with the experiment ID and commit SHA`,
466
+ `9. Call activate_experiment after deployment confirms`,
260
467
  ].join("\n")
261
468
  : [
262
469
  "SNIPPET MODE — no code changes needed.",
@@ -268,16 +475,207 @@ export const toolDefinitions = {
268
475
  return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
269
476
  },
270
477
  },
478
+ attach_experiment_asset: {
479
+ description: `${APEX} — Attach a variant screenshot to an experiment. Use after capturing a control/variant screenshot: for localhost or auth-gated pages the agent captures, pass imageBase64; for an already-hosted image, pass url. The stored asset shows on the dashboard experiment card and the experiment detail gallery.`,
480
+ schema: z.object({
481
+ experimentId: z.string().describe("The experiment ID to attach the screenshot to"),
482
+ variantKey: z.string().describe("control | variant_b | …"),
483
+ imageBase64: z.string().optional().describe("Base64-encoded PNG/JPEG. For localhost/auth-gated pages captured by the agent."),
484
+ url: z.string().url().optional().describe("Already-hosted public image URL."),
485
+ viewport: z.enum(["desktop", "tablet", "mobile"]).optional().describe("Viewport the screenshot was captured at (default desktop)."),
486
+ format: z.enum(["png", "jpeg"]).optional().describe("Image format (default png)."),
487
+ label: z.string().optional().describe("Optional caption for the asset."),
488
+ commitSha: z.string().optional().describe("Optional commit SHA the screenshot corresponds to."),
489
+ }),
490
+ handler: async (args) => {
491
+ if (!args.imageBase64 && !args.url) {
492
+ return {
493
+ content: [{
494
+ type: "text",
495
+ text: "Provide either imageBase64 (for localhost/auth-gated captures) or url (an already-hosted image).",
496
+ }],
497
+ isError: true,
498
+ };
499
+ }
500
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/assets`, {
501
+ variantKey: args.variantKey,
502
+ source: "agent",
503
+ viewport: args.viewport ?? "desktop",
504
+ imageBase64: args.imageBase64,
505
+ url: args.url,
506
+ format: args.format ?? "png",
507
+ label: args.label,
508
+ commitSha: args.commitSha,
509
+ });
510
+ const asset = res.data;
511
+ return {
512
+ content: [{
513
+ type: "text",
514
+ text: [
515
+ `${APEX} Screenshot attached to experiment ${args.experimentId}`,
516
+ ``,
517
+ ` Variant: ${asset.variantKey}`,
518
+ ` Viewport: ${asset.viewport}`,
519
+ ` Stored at: ${asset.url}`,
520
+ ``,
521
+ `It now shows on the dashboard experiment card and the experiment detail gallery.`,
522
+ ].join("\n"),
523
+ }],
524
+ };
525
+ },
526
+ },
527
+ verify_experiment_wiring: {
528
+ description: `${APEX} — Verify an experiment is actually wired up before launch: check whether \`experiment_exposure\` events are arriving for BOTH arms. Use this before activate_experiment to avoid launching an experiment that silently collects no data. Returns a per-arm exposure breakdown and an actionable next step.`,
529
+ schema: z.object({
530
+ experimentId: z.string().describe("The experiment ID to verify wiring for"),
531
+ }),
532
+ handler: async ({ experimentId }) => {
533
+ const wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
534
+ const armLines = wiring.perArm.map(formatArmLine);
535
+ const zeroArms = wiring.perArm.filter((a) => a.exposures === 0);
536
+ let verdictLine;
537
+ let nextStep;
538
+ switch (wiring.verdict) {
539
+ case "wired":
540
+ verdictLine = `WIRED — all ${wiring.totalArms} arm(s) are producing exposures`;
541
+ nextStep =
542
+ "Safe to activate. Call activate_experiment to launch — both arms are " +
543
+ "already receiving traffic and recording exposures.";
544
+ break;
545
+ case "partial": {
546
+ const names = zeroArms
547
+ .map((a) => `${a.label} (${a.variantKey})`)
548
+ .join(", ");
549
+ verdictLine = `PARTIAL — ${wiring.armsLive}/${wiring.totalArms} arm(s) live`;
550
+ nextStep =
551
+ `These arm(s) have ZERO exposures: ${names}. ` +
552
+ "Make sure the variant-serving code returns every variant key (not just the " +
553
+ "control) and that traffic is reaching each branch, then re-check. Don't " +
554
+ "activate until every arm is producing exposures.";
555
+ break;
556
+ }
557
+ case "no_exposures":
558
+ default:
559
+ verdictLine = "NO EXPOSURES — no exposures recorded for any arm";
560
+ nextStep =
561
+ "No `experiment_exposure` events are arriving. The variant-serving code " +
562
+ "(useApexVariant / Apex.getVariant) probably isn't deployed or running yet, " +
563
+ "so nothing is being assigned. Deploy the experiment code and confirm it runs " +
564
+ "on the target surface, then re-run verify_experiment_wiring.";
565
+ break;
566
+ }
567
+ const summary = [
568
+ `${APEX} Experiment Wiring Check`,
569
+ `${"═".repeat(40)}`,
570
+ ``,
571
+ ` Experiment: ${wiring.experimentId}`,
572
+ ` Surface: ${wiring.surface}`,
573
+ ` Status: ${wiring.status}`,
574
+ ` Verdict: ${verdictLine}`,
575
+ ` Total exposures: ${wiring.totalExposures}`,
576
+ ``,
577
+ ` Per-arm exposures:`,
578
+ ...armLines,
579
+ ``,
580
+ ` Next step: ${nextStep}`,
581
+ ``,
582
+ `${"═".repeat(40)}`,
583
+ ].join("\n");
584
+ return {
585
+ content: [
586
+ { type: "text", text: summary },
587
+ {
588
+ type: "text",
589
+ text: JSON.stringify({ _apex: true, _type: "wiring_check", ...wiring }, null, 2),
590
+ },
591
+ ],
592
+ };
593
+ },
594
+ },
271
595
  activate_experiment: {
272
- description: `${APEX} — Launch an experiment. Changes its status from draft to running. Traffic will be split immediately. Ask the user to confirm before calling.`,
596
+ 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) and refuses to launch an unwired experiment unless force is true. Ask the user to confirm before calling.`,
273
597
  schema: z.object({
274
598
  experimentId: z.string().describe("The experiment ID to activate"),
599
+ force: z
600
+ .boolean()
601
+ .optional()
602
+ .describe("Override the wiring safety check and activate even if both arms aren't producing exposures yet. Use only when you knowingly accept launching without verified wiring."),
275
603
  }),
276
- handler: async ({ experimentId }) => {
604
+ handler: async ({ experimentId, force }) => {
605
+ // Best-effort wiring gate: don't launch an experiment that silently
606
+ // collects no data. If the wiring endpoint itself errors, don't hard-block.
607
+ let wiring;
608
+ let wiringError;
609
+ try {
610
+ wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
611
+ }
612
+ catch (err) {
613
+ wiringError = err instanceof Error ? err.message : String(err);
614
+ }
615
+ if (wiring && !wiring.bothArmsLive && force !== true) {
616
+ const armLines = wiring.perArm.map(formatArmLine);
617
+ const zeroArms = wiring.perArm
618
+ .filter((a) => a.exposures === 0)
619
+ .map((a) => `${a.label} (${a.variantKey})`);
620
+ const refusal = [
621
+ `${APEX} Activation Refused — experiment is not wired up`,
622
+ `${"═".repeat(40)}`,
623
+ ``,
624
+ ` Did NOT launch ${experimentId}.`,
625
+ ``,
626
+ ` Both arms must be producing \`experiment_exposure\` events before`,
627
+ ` launch. Otherwise you'd start an experiment that silently collects`,
628
+ ` no data on one or more arms.`,
629
+ ``,
630
+ ` Verdict: ${wiring.verdict} (${wiring.armsLive}/${wiring.totalArms} arm(s) live)`,
631
+ ` Arm(s) with zero exposures: ${zeroArms.length ? zeroArms.join(", ") : "none"}`,
632
+ ``,
633
+ ` Per-arm exposures:`,
634
+ ...armLines,
635
+ ``,
636
+ ` To fix: wire up and deploy the variant-serving code`,
637
+ ` (useApexVariant / Apex.getVariant) so every arm records exposures,`,
638
+ ` then re-run activate_experiment. Or, to override this check and`,
639
+ ` launch anyway, re-run with force: true.`,
640
+ ``,
641
+ `${"═".repeat(40)}`,
642
+ ].join("\n");
643
+ return {
644
+ content: [
645
+ { type: "text", text: refusal },
646
+ {
647
+ type: "text",
648
+ text: JSON.stringify({
649
+ _apex: true,
650
+ _type: "activation_refused",
651
+ reason: "not_wired",
652
+ ...wiring,
653
+ }, null, 2),
654
+ },
655
+ ],
656
+ };
657
+ }
277
658
  const exp = await apiPatch("/api/experiments", {
278
659
  id: experimentId,
279
660
  status: "running",
280
661
  });
662
+ // PATCH returns a minimal/legacy body — re-read the unified record to
663
+ // derive the live traffic split from the allocation weights.
664
+ let controlPct = 50;
665
+ try {
666
+ const unified = await apiGet(`/api/experiments/${experimentId}?unified=true`);
667
+ controlPct = controlPctOf(unified);
668
+ }
669
+ catch { /* fall back to 50/50 in the message */ }
670
+ // Surface why the wiring gate didn't block: either it couldn't be
671
+ // verified (best-effort) or the user explicitly forced past it.
672
+ const notes = [];
673
+ if (wiringError) {
674
+ notes.push(` ⚠ Wiring could not be verified before launch (${wiringError}).`, ` Confirm both arms are recording exposures via verify_experiment_wiring.`, ``);
675
+ }
676
+ else if (wiring && !wiring.bothArmsLive && force === true) {
677
+ notes.push(` ⚠ Launched with force: true despite an incomplete wiring check`, ` (verdict: ${wiring.verdict}, ${wiring.armsLive}/${wiring.totalArms} arm(s) live).`, ` Some arm(s) may not be collecting data yet.`, ``);
678
+ }
281
679
  return {
282
680
  content: [
283
681
  {
@@ -288,8 +686,9 @@ export const toolDefinitions = {
288
686
  ``,
289
687
  ` "${exp.name}" is now LIVE`,
290
688
  ` ID: ${exp.id}`,
291
- ` Traffic is being split ${exp.trafficSplit}% / ${100 - exp.trafficSplit}%`,
689
+ ` Traffic is being split ${controlPct}% / ${100 - controlPct}%`,
292
690
  ``,
691
+ ...notes,
293
692
  ` Visitors will now see either the control or variant.`,
294
693
  ` Results will appear in the dashboard and via get_results.`,
295
694
  ``,
@@ -364,7 +763,7 @@ export const toolDefinitions = {
364
763
  experimentId: z.string().describe("The experiment ID to check"),
365
764
  }),
366
765
  handler: async ({ experimentId }) => {
367
- const exp = await apiGet(`/api/experiments/${experimentId}`);
766
+ const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
368
767
  if (!exp) {
369
768
  return { content: [{ type: "text", text: JSON.stringify({ _apex: true, error: "Experiment not found" }) }] };
370
769
  }
@@ -437,7 +836,7 @@ export const toolDefinitions = {
437
836
  .describe("Filter by status. Defaults to showing all except archived."),
438
837
  }),
439
838
  handler: async ({ status }) => {
440
- const experiments = await apiGet("/api/experiments");
839
+ const experiments = await apiGet("/api/experiments?unified=true");
441
840
  const filtered = status === "all"
442
841
  ? experiments
443
842
  : status
@@ -446,26 +845,27 @@ export const toolDefinitions = {
446
845
  if (filtered.length === 0) {
447
846
  return { content: [{ type: "text", text: `${APEX} No experiments found.` }] };
448
847
  }
449
- const pendingGraduation = filtered.filter((e) => e.promoted && !e.graduated && !e.graduationDismissed);
848
+ const pendingGraduation = filtered.filter((e) => e.promoted && !e.graduated);
450
849
  const lines = filtered.map((e) => {
451
- const ctrl = e.results?.control;
452
- const varB = e.results?.variant_b;
453
- const isPendingGrad = e.promoted && !e.graduated && !e.graduationDismissed;
850
+ const ctrl = e.results?.perVariant?.control;
851
+ const varB = e.results?.perVariant?.variant_b;
852
+ const isPendingGrad = !!(e.promoted && !e.graduated);
454
853
  const statusIcon = isPendingGrad
455
854
  ? "⬆"
456
855
  : e.status === "running" ? "●" : e.status === "draft" ? "○" : e.status === "paused" ? "◐" : "◉";
457
856
  const statusLabel = isPendingGrad
458
857
  ? `${e.status}] [PENDING GRADUATION`
459
858
  : e.status;
460
- const changeSummary = isPendingGrad && e.winner && e.variants?.[e.winner]?.changes?.length
461
- ? ` Changes to graduate: ${e.variants[e.winner].changes.map((c) => `"${c.originalValue ?? c.value}" → "${c.value}"`).join(", ")}`
859
+ const winnerVariant = e.winner ? findVariant(e, e.winner) : undefined;
860
+ const changeSummary = isPendingGrad && winnerVariant?.changes?.length
861
+ ? ` Changes to graduate: ${winnerVariant.changes.map((c) => `"${c.originalValue ?? c.value}" → "${c.value}"`).join(", ")}`
462
862
  : "";
463
863
  return [
464
864
  `${statusIcon} ${e.name} [${statusLabel}]`,
465
865
  ` ID: ${e.id}`,
466
- ` URL: ${e.targetUrl} | ${e.daysRunning ?? 0}d running`,
866
+ ` URL: ${e.targetUrl ?? "—"} | ${daysRunningOf(e)}d running`,
467
867
  ctrl && varB
468
- ? ` Control: ${ctrl.conversionRate}% CVR (${ctrl.visitors} visitors) | Variant: ${varB.conversionRate}% CVR (${varB.visitors} visitors)`
868
+ ? ` Control: ${ctrl.conversionRate ?? 0}% CVR (${ctrl.assigned} visitors) | Variant: ${varB.conversionRate ?? 0}% CVR (${varB.assigned} visitors)`
469
869
  : " No results yet",
470
870
  e.beliefId ? ` Belief: ${e.beliefId}` : "",
471
871
  changeSummary,
@@ -486,12 +886,12 @@ export const toolDefinitions = {
486
886
  experimentId: z.string().describe("The experiment ID"),
487
887
  }),
488
888
  handler: async ({ experimentId }) => {
489
- const exp = await apiGet(`/api/experiments/${experimentId}`);
889
+ const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
490
890
  if (!exp) {
491
891
  return { content: [{ type: "text", text: `${APEX} Experiment ${experimentId} not found.` }] };
492
892
  }
493
- const ctrl = exp.results?.control;
494
- const varB = exp.results?.variant_b;
893
+ const ctrl = exp.results?.perVariant?.control;
894
+ const varB = exp.results?.perVariant?.variant_b;
495
895
  return {
496
896
  content: [
497
897
  {
@@ -501,17 +901,17 @@ export const toolDefinitions = {
501
901
  `${"═".repeat(40)}`,
502
902
  ``,
503
903
  ` Status: ${exp.status}`,
504
- ` Running: ${exp.daysRunning ?? 0} days`,
904
+ ` Running: ${daysRunningOf(exp)} days`,
505
905
  ` Confidence: ${exp.confidence ?? 0}%`,
506
- ` URL: ${exp.targetUrl}`,
906
+ ` URL: ${exp.targetUrl ?? "—"}`,
507
907
  ``,
508
908
  ` Control:`,
509
909
  ctrl
510
- ? ` ${ctrl.visitors} visitors | ${ctrl.leads} conversions | ${ctrl.conversionRate}% CVR`
910
+ ? ` ${ctrl.assigned} visitors | ${ctrl.converted} conversions | ${ctrl.conversionRate ?? 0}% CVR`
511
911
  : " No data yet",
512
912
  ` Variant B:`,
513
913
  varB
514
- ? ` ${varB.visitors} visitors | ${varB.leads} conversions | ${varB.conversionRate}% CVR`
914
+ ? ` ${varB.assigned} visitors | ${varB.converted} conversions | ${varB.conversionRate ?? 0}% CVR`
515
915
  : " No data yet",
516
916
  exp.beliefId ? `\n Linked belief: ${exp.beliefId}` : "",
517
917
  ``,
@@ -541,7 +941,7 @@ export const toolDefinitions = {
541
941
  handler: async ({ context }) => {
542
942
  const [beliefs, experiments] = await Promise.all([
543
943
  apiGet("/api/beliefs"),
544
- apiGet("/api/experiments"),
944
+ apiGet("/api/experiments?unified=true"),
545
945
  ]);
546
946
  const lowConfidence = beliefs.filter((b) => b.confidence < 0.6 && b.confidence > 0.05);
547
947
  const untested = beliefs.filter((b) => b.confidence > 0.05 && !experiments.some((e) => e.beliefId === b.id));
@@ -574,10 +974,12 @@ export const toolDefinitions = {
574
974
  }
575
975
  if (relevantExperiments.length > 0) {
576
976
  recs.push(`**Past experiments in this area** (${relevantExperiments.length}):`, ...relevantExperiments.slice(0, 3).map((e) => {
577
- const ctrl = e.results?.control;
578
- const varB = e.results?.variant_b;
579
- const lift = ctrl && varB && ctrl.conversionRate > 0
580
- ? ((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate * 100).toFixed(1)
977
+ const ctrl = e.results?.perVariant?.control;
978
+ const varB = e.results?.perVariant?.variant_b;
979
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
980
+ const varCvr = varB?.conversionRate ?? 0;
981
+ const lift = ctrl && varB && ctrlCvr > 0
982
+ ? ((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)
581
983
  : "N/A";
582
984
  return ` - "${e.name}" — ${lift}% lift, ${e.confidence}% confidence`;
583
985
  }), "");
@@ -605,7 +1007,7 @@ export const toolDefinitions = {
605
1007
  winner: z.enum(["control", "variant_b"]).describe("Which variant won"),
606
1008
  }),
607
1009
  handler: async ({ experimentId, winner }) => {
608
- const existing = await apiGet(`/api/experiments/${experimentId}`);
1010
+ const existing = await apiGet(`/api/experiments/${experimentId}?unified=true`);
609
1011
  if (existing.status !== "completed" || !existing.winner) {
610
1012
  await apiPatch(`/api/experiments`, {
611
1013
  id: experimentId,
@@ -617,10 +1019,13 @@ export const toolDefinitions = {
617
1019
  id: experimentId,
618
1020
  action: "promote",
619
1021
  });
1022
+ // PATCH responses are minimal — winner content / belief link / mode come
1023
+ // from the unified record fetched at the top.
1024
+ const beliefId = existing.beliefId;
620
1025
  let beliefUpdate = "";
621
- if (promoted.beliefId) {
1026
+ if (beliefId) {
622
1027
  try {
623
- const result = await apiPost(`/api/beliefs/${promoted.beliefId}/update-confidence`, {
1028
+ const result = await apiPost(`/api/beliefs/${beliefId}/update-confidence`, {
624
1029
  experimentId,
625
1030
  outcome: winner === "variant_b" ? "confirmed" : "contradicted",
626
1031
  metricDelta: winner === "variant_b" ? 0.15 : -0.1,
@@ -631,11 +1036,11 @@ export const toolDefinitions = {
631
1036
  beliefUpdate = "Could not update assumption certainty automatically.";
632
1037
  }
633
1038
  }
634
- const winnerVariant = promoted.variants?.[winner];
1039
+ const winnerVariant = findVariant(existing, winner);
635
1040
  const changeSummary = winnerVariant?.changes?.length
636
1041
  ? winnerVariant.changes.map((c) => ` • ${c.type}: "${c.originalValue ?? "(unknown)"}" → "${c.value}" (${c.selector})`).join("\n")
637
1042
  : " No DOM changes recorded.";
638
- const nextStep = promoted.mode === "sdk"
1043
+ const nextStep = winnerVariant?.mode === "sdk"
639
1044
  ? `Use graduate_experiment("${experimentId}") to get cleanup instructions for removing useApexVariant() conditionals from your code.`
640
1045
  : `Use graduate_experiment("${experimentId}") to get instructions for making these changes permanent in your source code. The Apex snippet handles serving the winner in the meantime.`;
641
1046
  const output = [
@@ -829,7 +1234,7 @@ export const toolDefinitions = {
829
1234
  handler: async ({ feature, targetMetric }) => {
830
1235
  const [beliefs, experiments] = await Promise.all([
831
1236
  apiGet("/api/beliefs"),
832
- apiGet("/api/experiments"),
1237
+ apiGet("/api/experiments?unified=true"),
833
1238
  ]);
834
1239
  const keywords = feature
835
1240
  .toLowerCase()
@@ -850,10 +1255,12 @@ export const toolDefinitions = {
850
1255
  const lowConfRelevant = relevantBeliefs.filter((b) => b.confidence < 0.6);
851
1256
  const avgLift = relevantExperiments.length > 0
852
1257
  ? relevantExperiments.reduce((sum, e) => {
853
- const ctrl = e.results?.control;
854
- const varB = e.results?.variant_b;
855
- if (ctrl && varB && ctrl.conversionRate > 0) {
856
- return sum + ((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate) * 100;
1258
+ const ctrl = e.results?.perVariant?.control;
1259
+ const varB = e.results?.perVariant?.variant_b;
1260
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
1261
+ const varCvr = varB?.conversionRate ?? 0;
1262
+ if (ctrl && varB && ctrlCvr > 0) {
1263
+ return sum + ((varCvr - ctrlCvr) / ctrlCvr) * 100;
857
1264
  }
858
1265
  return sum;
859
1266
  }, 0) / relevantExperiments.length
@@ -874,10 +1281,12 @@ export const toolDefinitions = {
874
1281
  }
875
1282
  if (relevantExperiments.length > 0) {
876
1283
  lines.push(`**Historical evidence** (${relevantExperiments.length} related experiments):`, ...relevantExperiments.slice(0, 5).map((e) => {
877
- const ctrl = e.results?.control;
878
- const varB = e.results?.variant_b;
879
- const lift = ctrl && varB && ctrl.conversionRate > 0
880
- ? `${((varB.conversionRate - ctrl.conversionRate) / ctrl.conversionRate * 100).toFixed(1)}%`
1284
+ const ctrl = e.results?.perVariant?.control;
1285
+ const varB = e.results?.perVariant?.variant_b;
1286
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
1287
+ const varCvr = varB?.conversionRate ?? 0;
1288
+ const lift = ctrl && varB && ctrlCvr > 0
1289
+ ? `${((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)}%`
881
1290
  : "N/A";
882
1291
  return ` - "${e.name}": ${lift} lift`;
883
1292
  }), avgLift !== null ? ` Average lift in this area: ${avgLift > 0 ? "+" : ""}${avgLift.toFixed(1)}%` : "", "");
@@ -1152,44 +1561,19 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
1152
1561
  };
1153
1562
  },
1154
1563
  },
1564
+ // `switch_workspace` is canonical; `switch_project` is the deprecated alias
1565
+ // kept for the project→workspace rename. Both point at the same handler/
1566
+ // schema so existing agent calls keep working. (Auth-experience fix
1567
+ // 2026-06-13; deprecation alias formalized 2026-06-14.)
1568
+ switch_workspace: {
1569
+ description: "Switch the active workspace for this session. All subsequent API calls will use this workspace. After switching, Apex verifies it can read the workspace and warns if authentication or the workspace key looks wrong.",
1570
+ schema: switchWorkspaceSchema,
1571
+ handler: switchWorkspaceHandler,
1572
+ },
1155
1573
  switch_project: {
1156
- description: "Switch the active workspace for this session. All subsequent API calls will use this workspace.",
1157
- // `projectKey` is the deprecated alias of `workspaceKey` (the
1158
- // project→workspace rename). Accept either for one deprecation cycle —
1159
- // hard-rejecting `projectKey` broke existing agent calls and diverged
1160
- // from the SDK, which still aliases it. (Auth-experience fix 2026-06-13.)
1161
- schema: z.object({
1162
- workspaceKey: z
1163
- .string()
1164
- .optional()
1165
- .describe("The workspace key to switch to"),
1166
- projectKey: z
1167
- .string()
1168
- .optional()
1169
- .describe("Deprecated alias of workspaceKey. Use workspaceKey."),
1170
- }),
1171
- handler: async (args) => {
1172
- const key = args.workspaceKey ?? args.projectKey;
1173
- if (!key) {
1174
- return {
1175
- content: [{
1176
- type: "text",
1177
- text: "Provide a workspaceKey to switch to (projectKey is accepted as a deprecated alias).",
1178
- }],
1179
- isError: true,
1180
- };
1181
- }
1182
- setActiveWorkspace(key);
1183
- const deprecationNote = !args.workspaceKey && args.projectKey
1184
- ? " (note: `projectKey` is deprecated — use `workspaceKey`.)"
1185
- : "";
1186
- return {
1187
- content: [{
1188
- type: "text",
1189
- text: `Switched to workspace: ${key}. All subsequent calls will use this workspace.${deprecationNote}`,
1190
- }],
1191
- };
1192
- },
1574
+ description: "DEPRECATED alias of switch_workspace — use switch_workspace. Switches the active workspace for this session; all subsequent API calls use it.",
1575
+ schema: switchWorkspaceSchema,
1576
+ handler: switchWorkspaceHandler,
1193
1577
  },
1194
1578
  list_orgs: {
1195
1579
  description: "List all organizations you have access to, with their workspaces. Use this to see which orgs and workspaces are available.",