@apex-inc/mcp-server 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +19 -18
  2. package/dist/api-client.d.ts +5 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +40 -24
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/conflict-advisory.d.ts +29 -0
  7. package/dist/conflict-advisory.d.ts.map +1 -0
  8. package/dist/conflict-advisory.js +48 -0
  9. package/dist/conflict-advisory.js.map +1 -0
  10. package/dist/index.js +8 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/live-test-guard.d.ts +1 -0
  13. package/dist/live-test-guard.d.ts.map +1 -1
  14. package/dist/live-test-guard.js +4 -3
  15. package/dist/live-test-guard.js.map +1 -1
  16. package/dist/prompts.d.ts.map +1 -1
  17. package/dist/prompts.js +5 -7
  18. package/dist/prompts.js.map +1 -1
  19. package/dist/setup-catchup-tools.d.ts.map +1 -1
  20. package/dist/setup-catchup-tools.js +15 -3
  21. package/dist/setup-catchup-tools.js.map +1 -1
  22. package/dist/smoke-tools.d.ts +56 -0
  23. package/dist/smoke-tools.d.ts.map +1 -0
  24. package/dist/smoke-tools.js +261 -0
  25. package/dist/smoke-tools.js.map +1 -0
  26. package/dist/tool-catalog.d.ts +10 -0
  27. package/dist/tool-catalog.d.ts.map +1 -0
  28. package/dist/tool-catalog.js +36 -0
  29. package/dist/tool-catalog.js.map +1 -0
  30. package/dist/tool-groups.d.ts +5 -0
  31. package/dist/tool-groups.d.ts.map +1 -0
  32. package/dist/tool-groups.js +206 -0
  33. package/dist/tool-groups.js.map +1 -0
  34. package/dist/tool-shared.d.ts +300 -0
  35. package/dist/tool-shared.d.ts.map +1 -0
  36. package/dist/tool-shared.js +319 -0
  37. package/dist/tool-shared.js.map +1 -0
  38. package/dist/tools/accounts.d.ts +124 -0
  39. package/dist/tools/accounts.d.ts.map +1 -0
  40. package/dist/tools/accounts.js +99 -0
  41. package/dist/tools/accounts.js.map +1 -0
  42. package/dist/tools/adoption.d.ts +499 -0
  43. package/dist/tools/adoption.d.ts.map +1 -0
  44. package/dist/tools/adoption.js +447 -0
  45. package/dist/tools/adoption.js.map +1 -0
  46. package/dist/tools/context.d.ts +216 -0
  47. package/dist/tools/context.d.ts.map +1 -0
  48. package/dist/tools/context.js +328 -0
  49. package/dist/tools/context.js.map +1 -0
  50. package/dist/tools/experiments.d.ts +1066 -0
  51. package/dist/tools/experiments.d.ts.map +1 -0
  52. package/dist/tools/experiments.js +2090 -0
  53. package/dist/tools/experiments.js.map +1 -0
  54. package/dist/tools/letters.d.ts +1107 -0
  55. package/dist/tools/letters.d.ts.map +1 -0
  56. package/dist/tools/letters.js +1324 -0
  57. package/dist/tools/letters.js.map +1 -0
  58. package/dist/tools/measurement.d.ts +886 -0
  59. package/dist/tools/measurement.d.ts.map +1 -0
  60. package/dist/tools/measurement.js +1353 -0
  61. package/dist/tools/measurement.js.map +1 -0
  62. package/dist/tools/partners.d.ts +364 -0
  63. package/dist/tools/partners.d.ts.map +1 -0
  64. package/dist/tools/partners.js +199 -0
  65. package/dist/tools/partners.js.map +1 -0
  66. package/dist/tools.d.ts +3161 -2826
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +26 -6133
  69. package/dist/tools.js.map +1 -1
  70. package/package.json +3 -2
  71. package/skills/apex-communications/SKILL.md +5 -5
  72. package/skills/apex-experimentation/SKILL.md +11 -14
  73. package/skills/apex-growth-intelligence/SKILL.md +1 -1
  74. package/skills/apex-integration-cookbook/SKILL.md +1 -1
@@ -0,0 +1,2090 @@
1
+ import { z } from "zod";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { apiGet, apiPost, apiPatch, apiDelete, getActiveWorkspace, } from "../api-client.js";
5
+ import { resolveExperimentHypothesis } from "../experiment-copy.js";
6
+ import { activationLiveLine, shouldRefuseActivationWiring } from "../activation-wiring.js";
7
+ import { fetchLiveTestHits, formatLiveTestStop, isFileStop, isObjectStop, } from "../live-test-guard.js";
8
+ import { hasConflictAdvisory, renderConflictAdvisory, } from "../conflict-advisory.js";
9
+ import { APEX, controlPctOf, daysRunningOf, findVariant, formatArmLine, titleCaseEvent, assertTargetUrlForActiveWorkspace, errMsg, resolveCreateSurface, } from "../tool-shared.js";
10
+ export const experimentTools = {
11
+ plan_experiment: {
12
+ 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.`,
13
+ schema: z.object({
14
+ goal: z.string().describe("What the user wants to improve or test (e.g. 'improve signup conversion on landing page')"),
15
+ targetUrl: z.string().optional().describe("URL of the page to test, if known"),
16
+ }),
17
+ handler: async ({ goal, targetUrl }) => {
18
+ const [beliefs, experiments] = await Promise.all([
19
+ apiGet("/api/beliefs"),
20
+ apiGet("/api/experiments?unified=true"),
21
+ ]);
22
+ const keywords = goal
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9\s]/g, "")
25
+ .split(/\s+/)
26
+ .filter((w) => w.length > 3);
27
+ const relatedBeliefs = beliefs.filter((b) => {
28
+ const words = b.statement.toLowerCase();
29
+ return keywords.some((kw) => words.includes(kw));
30
+ });
31
+ const relatedExperiments = experiments.filter((e) => {
32
+ const words = e.name.toLowerCase();
33
+ return keywords.some((kw) => words.includes(kw));
34
+ });
35
+ const plan = {
36
+ _apex: true,
37
+ goal,
38
+ targetUrl: targetUrl || null,
39
+ relatedBeliefs: relatedBeliefs.slice(0, 5).map((b) => ({
40
+ id: b.id,
41
+ statement: b.statement,
42
+ confidence: Math.round(b.confidence * 100),
43
+ tested: experiments.some((e) => e.beliefId === b.id),
44
+ })),
45
+ relatedExperiments: relatedExperiments.slice(0, 3).map((e) => ({
46
+ id: e.id,
47
+ name: e.name,
48
+ status: e.status,
49
+ })),
50
+ suggestedBeliefs: [
51
+ `Users who see [specific change] will convert at a higher rate`,
52
+ `The current [element] is the primary friction point for [goal]`,
53
+ `A clearer value proposition will reduce bounce rate`,
54
+ ],
55
+ suggestedMetrics: [
56
+ "signup_conversion_rate",
57
+ "bounce_rate",
58
+ "click_through_rate",
59
+ "time_on_page",
60
+ ],
61
+ suggestedConfidenceLevels: [
62
+ { label: "Low (30%) — Gut feeling, no supporting data yet", value: 30 },
63
+ { label: "Medium (55%) — Some signals, but hasn't been tested", value: 55 },
64
+ { label: "High (75%) — Strong evidence from past experiments or data", value: 75 },
65
+ { label: "Very High (90%) — Near-certain, well-established pattern", value: 90 },
66
+ ],
67
+ suggestedTrafficAllocations: [
68
+ { label: "Adaptive (recommended) — Automatically shifts traffic toward the winning variant", control: 50, variant: 50, adaptive: true },
69
+ { label: "Balanced 50/50 — Equal split, fastest to reach significance", control: 50, variant: 50, adaptive: false },
70
+ { label: "Conservative 80/20 — Minimal exposure to variant, lower risk", control: 80, variant: 20, adaptive: false },
71
+ ],
72
+ _instructions: "IMPORTANT: Present each section as a numbered selection the user can pick from. Use a poll or numbered list for beliefs, confidence, metrics, and traffic split. Do NOT auto-fill any values.",
73
+ };
74
+ return {
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: JSON.stringify(plan, null, 2),
79
+ },
80
+ ],
81
+ };
82
+ },
83
+ },
84
+ start_reasoning: {
85
+ description: `${APEX} — Record a belief and hypothesis chain. Only call AFTER the user has picked a belief statement and confidence level from the options you presented.`,
86
+ schema: z.object({
87
+ decision: z.string().describe("What you want to improve or change"),
88
+ belief: z.string().describe("The assumption — must be confirmed by the user"),
89
+ confidence: z
90
+ .number()
91
+ .min(0)
92
+ .max(100)
93
+ .describe("How sure the user is about this assumption (0-100%) — must be chosen by the user"),
94
+ }),
95
+ handler: async ({ decision, belief, confidence }) => {
96
+ const b = await apiPost("/api/beliefs", {
97
+ statement: belief,
98
+ confidence: confidence / 100,
99
+ });
100
+ const hypothesis = `If we act on "${belief.slice(0, 80)}", then we should see a measurable improvement in our target metric.`;
101
+ const h = await apiPost("/api/hypotheses", {
102
+ beliefId: b.id,
103
+ statement: hypothesis,
104
+ predictedEffect: `Impact related to: ${decision.slice(0, 60)}`,
105
+ confidence: confidence / 100,
106
+ status: "active",
107
+ });
108
+ return {
109
+ content: [
110
+ {
111
+ type: "text",
112
+ text: [
113
+ `${APEX} Belief recorded`,
114
+ ``,
115
+ ` ID: ${b.id}`,
116
+ ` Statement: "${b.statement}"`,
117
+ ` Confidence: ${Math.round(b.confidence * 100)}%`,
118
+ ` Hypothesis: ${h.id}`,
119
+ ``,
120
+ `Present the user with these next steps as selections:`,
121
+ ` 1. Log a prediction — how much impact do you expect?`,
122
+ ` 2. Design the experiment — what should we change?`,
123
+ ` 3. Adjust the belief — refine the assumption first`,
124
+ ].join("\n"),
125
+ },
126
+ ],
127
+ };
128
+ },
129
+ },
130
+ create_belief: {
131
+ description: `${APEX} — Record an assumption your team is making. Confirm the belief statement and confidence with the user before calling.`,
132
+ schema: z.object({
133
+ statement: z.string().describe("The assumption statement"),
134
+ confidence: z.number().min(0).max(100).describe("How sure you are (0-100%)"),
135
+ parentBeliefId: z.string().optional().describe("ID of a parent assumption, if this is a sub-assumption"),
136
+ }),
137
+ handler: async ({ statement, confidence, parentBeliefId }) => {
138
+ const b = await apiPost("/api/beliefs", {
139
+ statement,
140
+ confidence: confidence / 100,
141
+ parentBeliefId,
142
+ });
143
+ return {
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `${APEX} Assumption tracked [${b.id}]: "${b.statement}" at ${Math.round(b.confidence * 100)}% certainty.`,
148
+ },
149
+ ],
150
+ };
151
+ },
152
+ },
153
+ create_experiment: {
154
+ 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.`,
155
+ schema: z.object({
156
+ name: z.string().describe("Experiment name"),
157
+ 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."),
158
+ controlContent: z.string().describe("The current content that the control shows"),
159
+ variantContent: z.string().describe("The new content for the variant"),
160
+ targetComponent: z.string().optional().describe("Component or file path where the change should be made"),
161
+ targetAnchor: z.string().optional().describe("Section anchor/ID on the page (e.g. 'pricing', 'hero')"),
162
+ trafficSplit: z.number().min(1).max(99).optional().describe("Percentage of traffic to control (default 50)"),
163
+ beliefId: z.string().optional().describe("ID of the assumption this tests"),
164
+ hypothesisId: z.string().optional().describe("ID of the expected outcome"),
165
+ predictionId: z.string().optional().describe("ID of the expected impact"),
166
+ 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."),
167
+ beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
168
+ hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
169
+ 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."),
170
+ 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."),
171
+ primaryMetricValueProperty: z.string().optional().describe("For revenue/duration goals: the event property to sum, e.g. 'value'. Defaults to 'value'."),
172
+ 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."),
173
+ 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 $)."),
174
+ guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
175
+ 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."),
176
+ surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' for a website, 'mobile' for a native / Capacitor / React-Native app. OPTIONAL — when omitted it is auto-detected from repo signals (capacitor.config.* / @apex-inc/capacitor-plugin → mobile) and the workspace's registered data sources; pass it explicitly only to override or when both web and mobile sources exist. Sets the dashboard data-source label (Website vs Mobile app)."),
177
+ 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."),
178
+ randomizationUnit: z.enum(["device", "visitor", "person", "account", "org"]).optional().describe("What to bucket on. Default 'visitor' (per-device). Use 'person' to give one stitched user the same variant across their devices, or 'account'/'org' for B2B account-level tests where everyone in an account should see the same variant."),
179
+ minSamplePerArm: z.number().int().min(30).optional().describe("Minimum exposed subjects PER ARM before a winner can be claimed (the sample gate). Defaults to 50. Raise it to demand more evidence (detect a smaller lift); lower it (floor 30) for low-traffic properties that would otherwise stall. The real winner-gate is still the 95% probability-to-be-best — this only sets the traffic floor. Pre-registration: frozen once the experiment is running."),
180
+ runtimeDays: z.number().int().min(1).max(365).optional().describe("How long to run the experiment, in days (the decision window). Apex force-concludes at this point — decisive winner if reached, else inconclusive. Defaults to 14. Set e.g. 28 to run for four weeks. This is the max runtime, not a minimum: a decisive winner can conclude sooner. Pre-registration: frozen once the experiment is running (fork to change it)."),
181
+ preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
182
+ }),
183
+ handler: async (args) => {
184
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
185
+ if (!targetCheck.ok) {
186
+ return {
187
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
188
+ isError: true,
189
+ };
190
+ }
191
+ const split = args.trafficSplit ?? 50;
192
+ // MCP experiments are ALWAYS code-native (SDK). Client-side snippet /
193
+ // runtime-DOM experiments are intentionally NOT offered here (founder
194
+ // decision 2026-07-05): the MCP audience is developers in an IDE with the
195
+ // code open, where `useApexVariant` branches in React (can change
196
+ // anything, needs a deploy, no fragile selectors, deterministic
197
+ // exposure). First HTML is flicker-free only when the merchant app
198
+ // assigns on the document request and passes that arm in. MCP does
199
+ // not serve the visitor page. Snippet experiments live in the Apex
200
+ // web app's visual editor for the no-code / non-repo case.
201
+ const mode = "sdk";
202
+ // Surface auto-detect (QA 2026-06-18): when the agent doesn't pass a
203
+ // surface, infer it from repo signals + registered data sources instead of
204
+ // silently defaulting to web (which mislabels mobile apps). Ambiguous →
205
+ // ask the agent to choose.
206
+ const surfaceResolution = await resolveCreateSurface(args.surface);
207
+ if ("ambiguous" in surfaceResolution) {
208
+ return {
209
+ content: [
210
+ {
211
+ type: "text",
212
+ text: `${APEX} This workspace has both web and mobile data sources — pass surface ("web" or "mobile") explicitly so the experiment is labeled and grouped correctly.`,
213
+ },
214
+ ],
215
+ isError: true,
216
+ };
217
+ }
218
+ const experimentSurface = surfaceResolution.surface;
219
+ const isPreview = args.preview !== false;
220
+ // p1-metric — resolve the primary metric (the conversion goal).
221
+ //
222
+ // Founder decision 2026-07-05: DO NOT silently default the conversion
223
+ // event to `form_submit`. A guessed goal ("Signups" that secretly counts
224
+ // form submits) is worse than none. When the caller doesn't name a
225
+ // conversion event, the draft is created UNWIRED (no `source`): it's a
226
+ // valid draft, but the activation gate blocks starting it until a real
227
+ // outcome event is set — and we nudge loudly to set one. An explicit
228
+ // primaryMetricEvent is validated against the workspace event spec.
229
+ const conversionEventSet = Boolean(args.primaryMetricEvent);
230
+ let primaryMetric = {
231
+ key: "conversion_goal",
232
+ label: "Conversion goal (not set)",
233
+ type: "rate",
234
+ unit: "%",
235
+ direction: "increase",
236
+ // No `source` — UNWIRED on purpose. Can't start until set.
237
+ };
238
+ if (args.primaryMetricEvent) {
239
+ const event = args.primaryMetricEvent.trim();
240
+ let validEvents = [];
241
+ try {
242
+ const spec = await apiGet("/api/spec/events");
243
+ validEvents = Object.keys(spec.data?.events ?? {});
244
+ }
245
+ catch (err) {
246
+ const message = err instanceof Error ? err.message : String(err);
247
+ return {
248
+ content: [{
249
+ type: "text",
250
+ text: `Could not load the event spec to validate primaryMetricEvent "${event}" (${message}). Fix the connection, or omit primaryMetricEvent to create the draft without a conversion goal (you'll set it before starting).`,
251
+ }],
252
+ isError: true,
253
+ };
254
+ }
255
+ if (validEvents.length && !validEvents.includes(event)) {
256
+ const sample = validEvents.slice(0, 12).join(", ");
257
+ return {
258
+ content: [{
259
+ type: "text",
260
+ 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.`,
261
+ }],
262
+ isError: true,
263
+ };
264
+ }
265
+ const metricType = args.primaryMetricType ?? "rate";
266
+ const isValue = metricType === "revenue" || metricType === "duration";
267
+ const valueProperty = args.primaryMetricValueProperty ?? "value";
268
+ primaryMetric = {
269
+ key: `${event}_${metricType}`,
270
+ label: `${titleCaseEvent(event)} ${metricType}`,
271
+ type: metricType,
272
+ unit: metricType === "revenue" ? "USD" : metricType === "duration" ? "s" : "%",
273
+ direction: "increase",
274
+ source: isValue
275
+ ? { kind: "event", eventType: event, property: valueProperty }
276
+ : { kind: "event", eventType: event },
277
+ };
278
+ }
279
+ if (isPreview) {
280
+ // Author-time conflict guard (best-effort, non-blocking): warn if this
281
+ // overlaps a LIVE experiment on the same surface before it's created.
282
+ let conflictAdvisory;
283
+ try {
284
+ const cc = await apiPost("/api/experiments/check-conflicts", {
285
+ surface: experimentSurface,
286
+ target_component: args.targetComponent,
287
+ target_url: args.targetUrl,
288
+ target_anchor: args.targetAnchor,
289
+ data_source_id: args.dataSourceId,
290
+ primary_metric_event: primaryMetric.source?.eventType,
291
+ });
292
+ if (hasConflictAdvisory(cc.data)) {
293
+ conflictAdvisory = renderConflictAdvisory(cc.data);
294
+ }
295
+ }
296
+ catch {
297
+ /* conflict check is advisory; never block the preview */
298
+ }
299
+ const recipe = {
300
+ _apex: true,
301
+ _type: "experiment_preview",
302
+ name: args.name,
303
+ surface: experimentSurface,
304
+ targetUrl: args.targetUrl,
305
+ targetAnchor: args.targetAnchor || null,
306
+ targetComponent: args.targetComponent || null,
307
+ mode,
308
+ trafficSplit: { control: split, variant: 100 - split },
309
+ decisionWindowDays: args.runtimeDays ?? 14,
310
+ minSamplePerArm: args.minSamplePerArm ?? 50,
311
+ control: args.controlContent,
312
+ variant: args.variantContent,
313
+ conversionGoal: conversionEventSet
314
+ ? { event: primaryMetric.source?.eventType, type: primaryMetric.type }
315
+ : "NOT SET — required before the experiment can start",
316
+ beliefId: args.beliefId || null,
317
+ beliefStatement: args.beliefStatement || null,
318
+ predictionId: args.predictionId || null,
319
+ ...(conflictAdvisory ? { _conflict_advisory: conflictAdvisory } : {}),
320
+ ...(conversionEventSet
321
+ ? {}
322
+ : {
323
+ _conversion_goal_warning: "No conversion event set. The draft will be created, but it CANNOT be started until you set the outcome you're optimizing. Call list_events to see what's firing (prefer a real outcome like signup/purchase over form_submit), then pass primaryMetricEvent. Confirm the goal with the user before creating.",
324
+ }),
325
+ _instructions: `Before proposing variant copy, ground every factual claim in .apex/brand-truth.md (read it; cite the line each claim traces to; if a claim isn't backed there, ask the user instead of inventing it).${conflictAdvisory ? " IMPORTANT: _conflict_advisory is set — show it to the user and resolve the overlap (mutex / variant / sequence) before creating." : ""}${conversionEventSet ? "" : " IMPORTANT: _conversion_goal_warning is set — surface it and set a conversion event (list_events → primaryMetricEvent) before creating, unless the user explicitly wants a draft to fill in later."} Then present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the experiment until confirmed.`,
326
+ };
327
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
328
+ }
329
+ // p2-linking — if the caller passed a belief statement instead of a
330
+ // pre-created beliefId, create the belief + hypothesis (mirroring
331
+ // start_reasoning) and link them to this experiment.
332
+ let beliefId = args.beliefId;
333
+ let hypothesisId = args.hypothesisId;
334
+ if (!beliefId && args.beliefStatement) {
335
+ const confidence = args.beliefConfidence != null ? args.beliefConfidence / 100 : 0.5;
336
+ const b = await apiPost("/api/beliefs", {
337
+ statement: args.beliefStatement,
338
+ confidence,
339
+ });
340
+ beliefId = b.id;
341
+ const hypothesisStatement = args.hypothesisStatement ??
342
+ `If we act on "${args.beliefStatement.slice(0, 80)}", then we should see a measurable improvement in "${args.name}".`;
343
+ const h = await apiPost("/api/hypotheses", {
344
+ beliefId: b.id,
345
+ statement: hypothesisStatement,
346
+ predictedEffect: `Impact related to: ${args.name.slice(0, 60)}`,
347
+ confidence,
348
+ status: "active",
349
+ });
350
+ hypothesisId = h.id;
351
+ }
352
+ // The API requires a non-empty hypothesis on the experiment record
353
+ // (validate-create rejects ""). Resolve one (explicit or derived).
354
+ const resolvedHypothesis = resolveExperimentHypothesis(args.hypothesisStatement, args.name, primaryMetric.label);
355
+ const controlW = split / 100;
356
+ const createdAtIso = new Date().toISOString();
357
+ // Variants must match the experiment surface (validate-create enforces
358
+ // this). Web arms carry DOMChange[]; mobile arms carry a JSON payload the
359
+ // app reads via Apex.getVariant(). Mislabeling a Capacitor app as web is
360
+ // exactly the bug this fixes.
361
+ const variants = experimentSurface === "mobile"
362
+ ? [
363
+ {
364
+ id: "v_control",
365
+ key: "control",
366
+ label: "Control",
367
+ surface: "mobile",
368
+ createdAt: createdAtIso,
369
+ description: "Original content",
370
+ payload: { content: args.controlContent },
371
+ },
372
+ {
373
+ id: "v_a",
374
+ key: "variant_a",
375
+ label: "Variant A",
376
+ surface: "mobile",
377
+ createdAt: createdAtIso,
378
+ description: "Modified content",
379
+ payload: { content: args.variantContent },
380
+ },
381
+ ]
382
+ : [
383
+ {
384
+ id: "v_control",
385
+ key: "control",
386
+ label: "Control",
387
+ surface: "web",
388
+ mode,
389
+ createdAt: createdAtIso,
390
+ description: "Original content",
391
+ changes: [],
392
+ },
393
+ {
394
+ id: "v_a",
395
+ key: "variant_a",
396
+ label: "Variant A",
397
+ surface: "web",
398
+ mode,
399
+ createdAt: createdAtIso,
400
+ description: "Modified content",
401
+ changes: [
402
+ {
403
+ selector: "*",
404
+ type: "text",
405
+ value: args.variantContent,
406
+ originalValue: args.controlContent,
407
+ },
408
+ ],
409
+ },
410
+ ];
411
+ const exp = await apiPost("/api/experiments", {
412
+ surface: experimentSurface,
413
+ name: args.name,
414
+ targetUrl: args.targetUrl,
415
+ targetAnchor: args.targetAnchor,
416
+ // Persist the component (e.g. "ProductCard") as a precise location
417
+ // signal for the author-time conflict guard — esp. mobile/SDK arms.
418
+ ...(args.targetComponent ? { targetComponent: args.targetComponent } : {}),
419
+ createdFrom: "cursor",
420
+ status: "draft",
421
+ ...(args.dataSourceId ? { dataSourceId: args.dataSourceId } : {}),
422
+ ...(args.randomizationUnit ? { randomizationUnit: args.randomizationUnit } : {}),
423
+ // Sample-size floor (minPerVariant) and/or run duration (durationDays).
424
+ // The API merges evaluationWindow, so sending one never clobbers the other.
425
+ ...(() => {
426
+ const ew = {};
427
+ if (args.minSamplePerArm)
428
+ ew.minPerVariant = args.minSamplePerArm;
429
+ if (args.runtimeDays)
430
+ ew.durationDays = args.runtimeDays;
431
+ return Object.keys(ew).length > 0 ? { evaluationWindow: ew } : {};
432
+ })(),
433
+ hypothesis: resolvedHypothesis,
434
+ // Agent-authored: tagged "agent" provenance so calibration attributes it
435
+ // correctly. When the agent supplies predictionMagnitude it satisfies the
436
+ // activation gate; an empty magnitude leaves the gate asking for one.
437
+ prediction: {
438
+ direction: "increase",
439
+ magnitude: args.predictionMagnitude?.trim() ?? "",
440
+ provenance: "agent",
441
+ },
442
+ confidence: 50,
443
+ beliefId,
444
+ hypothesisId,
445
+ predictionId: args.predictionId,
446
+ primaryMetric,
447
+ allocation: { strategy: "hash", weights: { control: controlW, variant_a: 1 - controlW } },
448
+ secondaryMetrics: [],
449
+ // Explicit EXTRA guardrail when the agent named one (orthogonal to the
450
+ // goal). When omitted, the server auto-attaches the conversion-model
451
+ // vital signs ("protected by default").
452
+ ...(args.guardrailEvent
453
+ ? {
454
+ guardrailMetrics: [
455
+ (() => {
456
+ const gType = args.guardrailType ?? "rate";
457
+ const isValue = gType === "revenue" || gType === "duration";
458
+ return {
459
+ key: `${args.guardrailEvent}_${gType}`,
460
+ label: `${titleCaseEvent(args.guardrailEvent)} ${gType === "rate" ? "rate" : gType}`,
461
+ type: gType,
462
+ unit: gType === "revenue" ? "USD" : gType === "duration" ? "s" : "%",
463
+ direction: "decrease",
464
+ source: isValue
465
+ ? { kind: "event", eventType: args.guardrailEvent, property: "value" }
466
+ : { kind: "event", eventType: args.guardrailEvent },
467
+ guardrailThreshold: args.guardrailThreshold ?? 0.25,
468
+ thresholdKind: "relative",
469
+ };
470
+ })(),
471
+ ],
472
+ }
473
+ : {}),
474
+ // VJATTR D1: 7-day exposure→conversion attribution window (was 24h).
475
+ attributionWindow: { unit: "days", value: 7, startFrom: "first_interaction" },
476
+ variants,
477
+ });
478
+ let previewUrl = "";
479
+ try {
480
+ const vu = new URL(exp.targetUrl || args.targetUrl);
481
+ vu.searchParams.set("_apex_preview", "variant_a");
482
+ vu.searchParams.set("_apex_exp", exp.id);
483
+ if (args.targetAnchor)
484
+ vu.hash = args.targetAnchor;
485
+ previewUrl = vu.toString();
486
+ }
487
+ catch { /* non-critical */ }
488
+ const isMobile = experimentSurface === "mobile";
489
+ // Grounding guidance — prepended to every recipe. The agent must write
490
+ // variant copy against the merchant's brand-truth file, not invent
491
+ // claims (the 2026-06-15 "free shipping on every order" incident, where
492
+ // the app actually charges $8 under $100).
493
+ const groundingGuidance = [
494
+ "GROUND THE COPY — do this BEFORE writing any variant text:",
495
+ "- Read .apex/brand-truth.md, .apex/brand-voice.md, and .apex/lexicon.md if they exist in the repo.",
496
+ "- Every factual claim in the variant copy MUST trace to a specific line in .apex/brand-truth.md. State which line each claim comes from. If a claim isn't backed there, ASK the user — do NOT invent it.",
497
+ "- If .apex/brand-truth.md is missing, scaffold the guardrails first (Apex → Set up Apex → Intelligence → \"Set up experiment guardrails\"), then ask the user to fill in the facts before writing copy.",
498
+ "",
499
+ ];
500
+ // Style/visual-change guidance — appended to every SDK recipe. The
501
+ // classic failure (QA 2026-06-15): a `bg-black` class collided with
502
+ // `bg-primary` because the codebase's cn() doesn't tailwind-merge, so
503
+ // both arms rendered identical and the test measured nothing.
504
+ const styleGuidance = [
505
+ "",
506
+ "VISUAL / STYLE CHANGES — read before editing:",
507
+ "- If the change is visual (color, size, layout), a variant that renders identical to control measures NOTHING. Always visually diff the two arms (screenshot both) before activating.",
508
+ "- Do NOT rely on a utility CLASS to override an existing one unless the codebase uses tailwind-merge. Plain className concatenation keeps BOTH classes (e.g. `bg-primary` + `bg-black`) and the original usually wins. Prefer an inline `style={{...}}` override or a class with guaranteed precedence.",
509
+ "- Change exactly ONE thing (the variable under test); keep everything else identical to control.",
510
+ ];
511
+ const webSteps = [
512
+ "IMPLEMENT THE EXPERIMENT IN CODE (web):",
513
+ `1. Open the target file${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
514
+ `2. Install: npm install @apex-inc/next @apex-inc/react`,
515
+ `3. Add: import { useApexVariant } from "@apex-inc/react"`,
516
+ `4. In the component, add: const variant = useApexVariant("${exp.id}") // fires experiment_exposure automatically`,
517
+ ` Then branch in React. That hook is not flicker-free by itself.`,
518
+ ` FIRST HTML / FLICKER: first HTML is flicker-free ONLY when it is already the assigned arm.`,
519
+ ` - SSR (Next, Remix): on the document request, mint or read the apex_vid cookie, assign with the same rules as GET /api/experiments/${exp.id}/assign, and pass that arm: useApexVariant("${exp.id}", { initialVariant, search }) or <ApexProvider assignments={{ "${exp.id}": variant }}>. { search } also makes _apex_preview match on first HTML.`,
520
+ ` - If you use Next.js: npm install @apex-inc/next @apex-inc/react, then apexVisitorMiddleware, assignExperiments, <ApexProvider assignments>. The hook alone is not flicker-free.`,
521
+ ` - Client-only SPA: the hook cannot know the arm before first HTML. Do not promise no flicker.`,
522
+ ` - Snippet mode (Apex mutates the DOM) is a different path. Do not conflate. Do not hide the page until /assign returns (worse LCP). That is not the React-hook promise.`,
523
+ ` - This MCP call does not serve the visitor page. create_experiment / activate_experiment do not remove flicker. The merchant app must pass the server arm.`,
524
+ ` - Pass the server arm into the hook or provider. @apex-inc/react accepts initialVariant and ApexProvider assignments.`,
525
+ `5. Wrap the target content in a conditional (fall back to control on null/error):`,
526
+ ` {variant === "variant_a" || variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
527
+ `6. Screenshots for the dashboard — REQUIRED before you tell the user the draft is ready:`,
528
+ ` - This is an SDK experiment. The variant exists only in local code until it ships.`,
529
+ ` - 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).`,
530
+ ` - 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.`,
531
+ ` - After the code is deployed, recapture_experiment_screenshots refreshes the live host.`,
532
+ `7. Show the user the diff. Dashboard "View live" links are built from target_url (not a hardcoded host). Preview the variant at: ${previewUrl}`,
533
+ ` If the merchant is QA'ing on localhost, the dashboard rewrites Apex app hosts to this origin so the unpublished variant is visible.`,
534
+ `8. After preview approval, commit and push`,
535
+ `9. Call track_deployment with the experiment ID and commit SHA`,
536
+ `10. Call activate_experiment (with the user's go-ahead). If the code isn't live yet, the`,
537
+ ` experiment holds in "Waiting on deploy" and starts automatically at the first exposure —`,
538
+ ` verify with verify_experiment_wiring after the deploy lands.`,
539
+ ];
540
+ const mobileSteps = [
541
+ "IMPLEMENT THE EXPERIMENT IN CODE (mobile / Capacitor):",
542
+ `1. Open the target screen${args.targetComponent ? ` (${args.targetComponent})` : ""}`,
543
+ `2. Install the plugin: npm i @apex-inc/capacitor-plugin`,
544
+ `3. Add: import { Apex } from "@apex-inc/capacitor-plugin"`,
545
+ `4. Resolve the variant: const { value: variant } = await Apex.getVariant({ experimentId: "${exp.id}" }) // fires experiment_exposure automatically`,
546
+ `5. Render conditionally (fall back to control on null/error): variant === "variant_a" || variant === "variant_b" ? <VARIANT> : <CONTROL>`,
547
+ `6. Screenshot both arms — add ONE debug-gated line on the screen that renders the variant, keyed to the resolved variant so it fires once the arm is on-screen:`,
548
+ ` useEffect(() => { if (variant) Apex.captureVariantScreenshot({ experimentId: "${exp.id}", variantKey: variant }); }, [variant]);`,
549
+ ` It is debug-gated (init Apex with debug:true) — a no-op in production, so real users are never screenshotted.`,
550
+ `7. Show the user the diff and a side-by-side of both arms`,
551
+ `8. After approval, commit and push`,
552
+ `9. Call track_deployment with the experiment ID and commit SHA`,
553
+ `10. Call verify_experiment_wiring, then activate_experiment once both arms report exposures`,
554
+ ];
555
+ // Autonomous-agent activation policy (Phase 6): the experiment is created
556
+ // as a DRAFT. Activating it spends metered usage and exposes real users,
557
+ // so an agent must NOT auto-activate — surface the cost and get explicit
558
+ // human approval first. The server's activation gate also independently
559
+ // requires the full spine (measurable goal + guardrail + prediction).
560
+ const activationPolicy = [
561
+ "",
562
+ "ACTIVATION POLICY — do not auto-activate:",
563
+ "- This experiment is a DRAFT. It is NOT collecting data yet.",
564
+ "- Activating meters usage (one billable orchestration per variant assignment) and shows variants to real users.",
565
+ "- Before calling activate_experiment, tell the user the expected metered volume and get their explicit go-ahead.",
566
+ "- 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.",
567
+ ];
568
+ const conversionGoalNudge = conversionEventSet
569
+ ? []
570
+ : [
571
+ "",
572
+ "⚠️ CONVERSION GOAL NOT SET — this draft cannot be started yet:",
573
+ "- No outcome event is wired, so there's nothing to measure a winner on.",
574
+ "- Pick the real outcome: call list_events to see what's firing in this",
575
+ " workspace, then update_experiment({ experiment_id, primary_metric_event }).",
576
+ "- Prefer a true outcome (signup / purchase / checkout_completed) over a",
577
+ " proxy like form_submit. Confirm the goal with the user.",
578
+ "- activate_experiment will refuse until a measurable goal is set.",
579
+ ];
580
+ const recipe = {
581
+ _apex: true,
582
+ _type: "experiment_created",
583
+ experimentId: exp.id,
584
+ name: exp.name,
585
+ surface: experimentSurface,
586
+ status: exp.status,
587
+ mode,
588
+ conversionGoal: conversionEventSet
589
+ ? { event: primaryMetric.source?.eventType, type: primaryMetric.type }
590
+ : "NOT SET — required before start (see _instructions)",
591
+ decisionWindowDays: args.runtimeDays ?? 14,
592
+ minSamplePerArm: args.minSamplePerArm ?? 50,
593
+ beliefId: beliefId || null,
594
+ hypothesisId: hypothesisId || null,
595
+ predictionId: args.predictionId || null,
596
+ sdk: mode === "sdk" ? {
597
+ ...(isMobile
598
+ ? {
599
+ method: "Apex.getVariant",
600
+ import: "@apex-inc/capacitor-plugin",
601
+ install: "npm i @apex-inc/capacitor-plugin",
602
+ }
603
+ : {
604
+ hook: "useApexVariant",
605
+ import: "@apex-inc/react",
606
+ install: "npm install @apex-inc/next @apex-inc/react",
607
+ }),
608
+ experimentId: exp.id,
609
+ targetComponent: args.targetComponent || null,
610
+ targetAnchor: args.targetAnchor || null,
611
+ control: args.controlContent,
612
+ variant: args.variantContent,
613
+ } : null,
614
+ previewUrl,
615
+ trafficSplit: { control: split, variant: 100 - split },
616
+ // Always code-native (SDK) — see the `mode` note above.
617
+ _instructions: groundingGuidance
618
+ .concat(isMobile ? mobileSteps : webSteps)
619
+ .concat(styleGuidance)
620
+ .concat(activationPolicy)
621
+ .concat(conversionGoalNudge)
622
+ .join("\n"),
623
+ };
624
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
625
+ },
626
+ },
627
+ attach_experiment_asset: {
628
+ 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.`,
629
+ schema: z.object({
630
+ experimentId: z.string().describe("The experiment ID to attach the screenshot to"),
631
+ variantKey: z.string().describe("control | variant_b | …"),
632
+ imageBase64: z.string().optional().describe("Base64-encoded PNG/JPEG. For localhost/auth-gated pages captured by the agent."),
633
+ url: z.string().url().optional().describe("Already-hosted public image URL."),
634
+ viewport: z.enum(["desktop", "tablet", "mobile"]).optional().describe("Viewport the screenshot was captured at (default desktop)."),
635
+ format: z.enum(["png", "jpeg"]).optional().describe("Image format (default png)."),
636
+ label: z.string().optional().describe("Optional caption for the asset."),
637
+ commitSha: z.string().optional().describe("Optional commit SHA the screenshot corresponds to."),
638
+ }),
639
+ handler: async (args) => {
640
+ if (!args.imageBase64 && !args.url) {
641
+ return {
642
+ content: [{
643
+ type: "text",
644
+ text: "Provide either imageBase64 (for localhost/auth-gated captures) or url (an already-hosted image).",
645
+ }],
646
+ isError: true,
647
+ };
648
+ }
649
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/assets`, {
650
+ variantKey: args.variantKey,
651
+ source: "agent",
652
+ viewport: args.viewport ?? "desktop",
653
+ imageBase64: args.imageBase64,
654
+ url: args.url,
655
+ format: args.format ?? "png",
656
+ label: args.label,
657
+ commitSha: args.commitSha,
658
+ });
659
+ const asset = res.data;
660
+ return {
661
+ content: [{
662
+ type: "text",
663
+ text: [
664
+ `${APEX} Screenshot attached to experiment ${args.experimentId}`,
665
+ ``,
666
+ ` Variant: ${asset.variantKey}`,
667
+ ` Viewport: ${asset.viewport}`,
668
+ ` Stored at: ${asset.url}`,
669
+ ``,
670
+ `It now shows on the dashboard experiment card and the experiment detail gallery.`,
671
+ ].join("\n"),
672
+ }],
673
+ };
674
+ },
675
+ },
676
+ recapture_experiment_screenshots: {
677
+ 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.`,
678
+ schema: z.object({
679
+ experimentId: z.string().describe("The experiment ID to re-capture screenshots for."),
680
+ }),
681
+ handler: async ({ experimentId }) => {
682
+ try {
683
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/recapture`, {});
684
+ const { captured, errors } = res.data;
685
+ const lines = [
686
+ `${APEX} Re-captured ${captured} screenshot${captured === 1 ? "" : "s"} for ${experimentId}.`,
687
+ ];
688
+ if (errors.length)
689
+ lines.push(` Arm(s) that failed: ${errors.join("; ")}`);
690
+ if (captured === 0) {
691
+ lines.push(` No images were produced — confirm the target URL is public and reachable.`);
692
+ }
693
+ return { content: [{ type: "text", text: lines.join("\n") }] };
694
+ }
695
+ catch (err) {
696
+ return {
697
+ content: [
698
+ {
699
+ type: "text",
700
+ text: `${APEX} ${err instanceof Error ? err.message : String(err)}`,
701
+ },
702
+ ],
703
+ isError: true,
704
+ };
705
+ }
706
+ },
707
+ },
708
+ verify_experiment_wiring: {
709
+ 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.`,
710
+ schema: z.object({
711
+ experimentId: z.string().describe("The experiment ID to verify wiring for"),
712
+ }),
713
+ handler: async ({ experimentId }) => {
714
+ const wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
715
+ const armLines = wiring.perArm.map(formatArmLine);
716
+ const zeroArms = wiring.perArm.filter((a) => a.exposures === 0);
717
+ let verdictLine;
718
+ let nextStep;
719
+ switch (wiring.verdict) {
720
+ case "wired":
721
+ verdictLine = `WIRED — all ${wiring.totalArms} arm(s) are producing exposures`;
722
+ nextStep =
723
+ "Safe to activate. Call activate_experiment to launch — both arms are " +
724
+ "already receiving traffic and recording exposures.";
725
+ break;
726
+ case "partial": {
727
+ const names = zeroArms
728
+ .map((a) => `${a.label} (${a.variantKey})`)
729
+ .join(", ");
730
+ verdictLine = `PARTIAL — ${wiring.armsLive}/${wiring.totalArms} arm(s) live`;
731
+ nextStep =
732
+ `These arm(s) have ZERO exposures: ${names}. ` +
733
+ "Make sure the variant-serving code returns every variant key (not just the " +
734
+ "control) and that traffic is reaching each branch, then re-check. Don't " +
735
+ "activate until every arm is producing exposures.";
736
+ break;
737
+ }
738
+ case "no_exposures":
739
+ default:
740
+ verdictLine = "NO EXPOSURES — no exposures recorded for any arm";
741
+ nextStep = wiring.sdkMode
742
+ ? "No `experiment_exposure` events have reached Apex yet. This experiment is " +
743
+ "code-wired (SDK hook), so you can activate it NOW: activate_experiment will " +
744
+ "hold it in `pending_deployment` (\"Waiting on deploy\") and it starts " +
745
+ "automatically the moment the deployed `useApexVariant(\"" +
746
+ wiring.experimentId +
747
+ "\")` code records its first exposure. Ship the code, and the test starts itself."
748
+ : "No `experiment_exposure` events are arriving. The variant-serving code " +
749
+ "(useApexVariant / Apex.getVariant) probably isn't deployed or running yet, " +
750
+ "so nothing is being assigned. Deploy the experiment code and confirm it runs " +
751
+ "on the target surface, then re-run verify_experiment_wiring.";
752
+ break;
753
+ }
754
+ const summary = [
755
+ `${APEX} Experiment Wiring Check`,
756
+ `${"═".repeat(40)}`,
757
+ ``,
758
+ ` Experiment: ${wiring.experimentId}`,
759
+ ` Surface: ${wiring.surface}`,
760
+ ` Status: ${wiring.status}`,
761
+ ` Verdict: ${verdictLine}`,
762
+ ` Total exposures: ${wiring.totalExposures}`,
763
+ ``,
764
+ ` Per-arm exposures:`,
765
+ ...armLines,
766
+ ``,
767
+ ` Next step: ${nextStep}`,
768
+ ``,
769
+ `${"═".repeat(40)}`,
770
+ ].join("\n");
771
+ return {
772
+ content: [
773
+ { type: "text", text: summary },
774
+ {
775
+ type: "text",
776
+ text: JSON.stringify({ _apex: true, _type: "wiring_check", ...wiring }, null, 2),
777
+ },
778
+ ],
779
+ };
780
+ },
781
+ },
782
+ activate_experiment: {
783
+ 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.`,
784
+ schema: z.object({
785
+ experimentId: z.string().describe("The experiment ID to activate"),
786
+ force: z
787
+ .boolean()
788
+ .optional()
789
+ .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."),
790
+ }),
791
+ handler: async ({ experimentId, force }) => {
792
+ // Best-effort wiring gate: don't launch an experiment that silently
793
+ // collects no data. If the wiring endpoint itself errors, don't hard-block.
794
+ let wiring;
795
+ let wiringError;
796
+ try {
797
+ wiring = (await apiGet(`/api/experiments/${experimentId}/wiring`)).data;
798
+ }
799
+ catch (err) {
800
+ wiringError = err instanceof Error ? err.message : String(err);
801
+ }
802
+ // Code-wired (SDK-hook) AND snippet-applied experiments skip the hard
803
+ // refusal. SDK: the server holds them in `pending_deployment` and
804
+ // auto-starts on first exposure. Snippet: the Apex snippet only applies
805
+ // the experiment ONCE IT'S RUNNING, so it can't have pre-activation
806
+ // exposures — blocking on them is a chicken-and-egg. Both are safe to
807
+ // launch "early"; wiring is verified AFTER via verify_experiment_wiring.
808
+ if (wiring && shouldRefuseActivationWiring(wiring, force)) {
809
+ const armLines = wiring.perArm.map(formatArmLine);
810
+ const zeroArms = wiring.perArm
811
+ .filter((a) => a.exposures === 0)
812
+ .map((a) => `${a.label} (${a.variantKey})`);
813
+ const refusal = [
814
+ `${APEX} Activation Refused — experiment is not wired up`,
815
+ `${"═".repeat(40)}`,
816
+ ``,
817
+ ` Did NOT launch ${experimentId}.`,
818
+ ``,
819
+ ` Both arms must be producing \`experiment_exposure\` events before`,
820
+ ` launch. Otherwise you'd start an experiment that silently collects`,
821
+ ` no data on one or more arms.`,
822
+ ``,
823
+ ` Verdict: ${wiring.verdict} (${wiring.armsLive}/${wiring.totalArms} arm(s) live)`,
824
+ ` Arm(s) with zero exposures: ${zeroArms.length ? zeroArms.join(", ") : "none"}`,
825
+ ``,
826
+ ` Per-arm exposures:`,
827
+ ...armLines,
828
+ ``,
829
+ ` To fix: wire up and deploy the variant-serving code`,
830
+ ` (useApexVariant / Apex.getVariant) so every arm records exposures,`,
831
+ ` then re-run activate_experiment. Or, to override this check and`,
832
+ ` launch anyway, re-run with force: true.`,
833
+ ``,
834
+ `${"═".repeat(40)}`,
835
+ ].join("\n");
836
+ return {
837
+ content: [
838
+ { type: "text", text: refusal },
839
+ {
840
+ type: "text",
841
+ text: JSON.stringify({
842
+ _apex: true,
843
+ _type: "activation_refused",
844
+ reason: "not_wired",
845
+ ...wiring,
846
+ }, null, 2),
847
+ },
848
+ ],
849
+ };
850
+ }
851
+ const exp = await apiPatch("/api/experiments", {
852
+ id: experimentId,
853
+ status: "running",
854
+ // Deploy-wait override: the server holds an unwired code-wired
855
+ // experiment in `pending_deployment` unless the caller forces.
856
+ ...(force === true ? { force: true } : {}),
857
+ });
858
+ // Deploy-wait hold — the server accepted the start but is waiting for
859
+ // the experiment code to go live. Report the held state precisely
860
+ // (claiming "LIVE" here would repeat the 2026-07-04 confusion).
861
+ if (exp.status === "pending_deployment") {
862
+ const held = [
863
+ `${APEX} Experiment Waiting on Deploy`,
864
+ `${"═".repeat(40)}`,
865
+ ``,
866
+ ` "${exp.name}" is armed but NOT collecting yet.`,
867
+ ` ID: ${exp.id}`,
868
+ ` Status: pending_deployment ("Waiting on deploy")`,
869
+ ``,
870
+ ` Apex hasn't received any \`experiment_exposure\` events from this`,
871
+ ` experiment's code, so the start is on hold. Nothing is wrong —`,
872
+ ` the experiment starts AUTOMATICALLY the moment the deployed code`,
873
+ ` records its first exposure.`,
874
+ ``,
875
+ ` To go live:`,
876
+ ` 1. Render the variants through the hook:`,
877
+ ` const variant = useApexVariant("${exp.id}");`,
878
+ ` On SSR, assign on the document request and pass that arm`,
879
+ ` (initialVariant or <ApexProvider assignments>). The hook`,
880
+ ` alone is not flicker-free. If you use Next.js:`,
881
+ ` npm install @apex-inc/next @apex-inc/react, then`,
882
+ ` apexVisitorMiddleware, assignExperiments,`,
883
+ ` <ApexProvider assignments>. Creating this experiment`,
884
+ ` did not remove flicker.`,
885
+ ` 2. Deploy that code to the target page.`,
886
+ ` 3. Done — the first visitor flips it to running. Verify with`,
887
+ ` verify_experiment_wiring or the experiment page.`,
888
+ ``,
889
+ ` Certain the code is already live? Re-run activate_experiment`,
890
+ ` with force: true to start immediately.`,
891
+ ``,
892
+ `${"═".repeat(40)}`,
893
+ ].join("\n");
894
+ return {
895
+ content: [
896
+ { type: "text", text: held },
897
+ {
898
+ type: "text",
899
+ text: JSON.stringify({
900
+ _apex: true,
901
+ _type: "activation_held",
902
+ reason: "code_not_live",
903
+ experimentId: exp.id,
904
+ status: exp.status,
905
+ autoStartsOnFirstExposure: true,
906
+ }, null, 2),
907
+ },
908
+ ],
909
+ };
910
+ }
911
+ // PATCH returns a minimal/legacy body — re-read the unified record to
912
+ // derive the live traffic split from the allocation weights.
913
+ let controlPct = 50;
914
+ let liveSurface = exp.surface;
915
+ try {
916
+ const unified = await apiGet(`/api/experiments/${experimentId}?unified=true`);
917
+ controlPct = controlPctOf(unified);
918
+ liveSurface = unified.surface ?? liveSurface;
919
+ }
920
+ catch { /* fall back to 50/50 in the message */ }
921
+ // Surface why the wiring gate didn't block: either it couldn't be
922
+ // verified (best-effort) or the user explicitly forced past it.
923
+ const notes = [];
924
+ if (wiringError) {
925
+ notes.push(` ⚠ Wiring could not be verified before launch (${wiringError}).`, ` Confirm both arms are recording exposures via verify_experiment_wiring.`, ``);
926
+ }
927
+ else if (wiring && !wiring.bothArmsLive && force === true) {
928
+ 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.`, ``);
929
+ }
930
+ else if (wiring && !wiring.bothArmsLive && wiring.snippetMode) {
931
+ notes.push(` ℹ Snippet experiment — the Apex snippet applies it to visitors now`, ` that it's running, so exposures start flowing on the next visits.`, ` Confirm both arms record exposures shortly with`, ` verify_experiment_wiring (a mismatched selector would silently`, ` collect nothing).`, ``);
932
+ }
933
+ // Author-time conflict guard (advisory): if this now-running experiment
934
+ // overlaps another live one on the same surface, surface the fix.
935
+ try {
936
+ const cc = await apiPost("/api/experiments/check-conflicts", { experiment_id: experimentId });
937
+ if (hasConflictAdvisory(cc.data)) {
938
+ notes.push(` ${renderConflictAdvisory(cc.data, experimentId).split("\n").join("\n ")}`, ``);
939
+ }
940
+ }
941
+ catch {
942
+ /* advisory only — never affect the launch result */
943
+ }
944
+ return {
945
+ content: [
946
+ {
947
+ type: "text",
948
+ text: [
949
+ `${APEX} Experiment Launched`,
950
+ `${"═".repeat(40)}`,
951
+ ``,
952
+ ` "${exp.name}" is now LIVE`,
953
+ ` ID: ${exp.id}`,
954
+ ` Traffic is being split ${controlPct}% / ${100 - controlPct}%`,
955
+ ``,
956
+ ...notes,
957
+ ` ${activationLiveLine(liveSurface)}`,
958
+ ` Results will appear in the dashboard and via get_results.`,
959
+ ``,
960
+ `${"═".repeat(40)}`,
961
+ ].join("\n"),
962
+ },
963
+ ],
964
+ };
965
+ },
966
+ },
967
+ pause_experiment: {
968
+ description: `${APEX} — Pause a running experiment. All visitors will see the control until resumed. Ask the user to confirm before calling.`,
969
+ schema: z.object({
970
+ experimentId: z.string().describe("The experiment ID to pause"),
971
+ }),
972
+ handler: async ({ experimentId }) => {
973
+ const exp = await apiPatch("/api/experiments", {
974
+ id: experimentId,
975
+ status: "paused",
976
+ });
977
+ return {
978
+ content: [
979
+ {
980
+ type: "text",
981
+ text: [
982
+ `${APEX} Experiment Paused`,
983
+ ``,
984
+ ` "${exp.name}" is now paused.`,
985
+ ` All visitors will see the control version.`,
986
+ ` Data collected so far is preserved.`,
987
+ ``,
988
+ ` To resume, use activate_experiment.`,
989
+ ].join("\n"),
990
+ },
991
+ ],
992
+ };
993
+ },
994
+ },
995
+ track_deployment: {
996
+ description: `${APEX} — Record that an experiment's code has been committed and pushed. Stores the commit SHA and monitors deployment status. Call after pushing the experiment code.`,
997
+ schema: z.object({
998
+ experimentId: z.string().describe("The experiment ID"),
999
+ commitSha: z.string().describe("The git commit SHA containing the experiment code"),
1000
+ branch: z.string().optional().describe("The git branch (default: main)"),
1001
+ }),
1002
+ handler: async ({ experimentId, commitSha, branch }) => {
1003
+ const exp = await apiPatch("/api/experiments", {
1004
+ id: experimentId,
1005
+ deploymentCommit: commitSha,
1006
+ deploymentStatus: "building",
1007
+ });
1008
+ const recipe = {
1009
+ _apex: true,
1010
+ _type: "deployment_tracked",
1011
+ experimentId: exp.id,
1012
+ commitSha,
1013
+ branch: branch || "main",
1014
+ deploymentStatus: "building",
1015
+ _instructions: [
1016
+ "The experiment code is now being deployed.",
1017
+ "Wait for the build to complete, then call check_deployment.",
1018
+ "Once deployed, call activate_experiment to start collecting data.",
1019
+ "Ask the user if they want to wait for deployment or activate later.",
1020
+ ].join("\n"),
1021
+ };
1022
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
1023
+ },
1024
+ },
1025
+ check_deployment: {
1026
+ description: `${APEX} — Check if an experiment's code has been deployed. Verifies that the assignment endpoint responds for this experiment. Call after track_deployment.`,
1027
+ schema: z.object({
1028
+ experimentId: z.string().describe("The experiment ID to check"),
1029
+ }),
1030
+ handler: async ({ experimentId }) => {
1031
+ const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
1032
+ if (!exp) {
1033
+ return { content: [{ type: "text", text: JSON.stringify({ _apex: true, error: "Experiment not found" }) }] };
1034
+ }
1035
+ let isReachable = false;
1036
+ try {
1037
+ const BASE_URL = process.env.APEX_URL || "http://localhost:3001";
1038
+ const res = await fetch(`${BASE_URL}/api/experiments/${experimentId}/assign`, {
1039
+ signal: AbortSignal.timeout(5000),
1040
+ });
1041
+ isReachable = res.ok;
1042
+ }
1043
+ catch { /* unreachable */ }
1044
+ const status = isReachable ? "deployed" : (exp.deploymentStatus || "pending");
1045
+ if (isReachable && exp.deploymentStatus !== "deployed") {
1046
+ await apiPatch("/api/experiments", {
1047
+ id: experimentId,
1048
+ deploymentStatus: "deployed",
1049
+ deployedAt: new Date().toISOString(),
1050
+ });
1051
+ }
1052
+ const recipe = {
1053
+ _apex: true,
1054
+ _type: "deployment_check",
1055
+ experimentId,
1056
+ deploymentStatus: status,
1057
+ commitSha: exp.deploymentCommit || null,
1058
+ isReachable,
1059
+ _instructions: isReachable
1060
+ ? "Deployment confirmed. Ask the user if they want to activate the experiment now."
1061
+ : "Deployment not yet confirmed. The build may still be in progress. Try again in a minute or ask the user to check their CI/CD.",
1062
+ };
1063
+ return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
1064
+ },
1065
+ },
1066
+ archive_experiment: {
1067
+ description: `${APEX} — Archive an experiment. Removes it from active views but preserves all data, learnings, and belief graph connections. Archived experiments still contribute to intelligence scoring and recommendations. A RUNNING experiment cannot be archived — end it first (conclude_experiment / promote_winner) so its result + belief are captured; the server returns 409 archive_requires_conclusion otherwise. Ask the user to confirm before calling.`,
1068
+ schema: z.object({
1069
+ experimentId: z.string().describe("The experiment ID to archive"),
1070
+ }),
1071
+ handler: async ({ experimentId }) => {
1072
+ const exp = await apiPatch("/api/experiments", {
1073
+ id: experimentId,
1074
+ status: "archived",
1075
+ });
1076
+ return {
1077
+ content: [
1078
+ {
1079
+ type: "text",
1080
+ text: [
1081
+ `${APEX} Experiment Archived`,
1082
+ ``,
1083
+ ` "${exp.name}" has been archived.`,
1084
+ ` It no longer appears in active views, but all data is preserved.`,
1085
+ ` Learnings and belief graph connections remain intact.`,
1086
+ ``,
1087
+ ` To view archived experiments: list_experiments with status "archived"`,
1088
+ ` To restore: activate_experiment to resume, or pause_experiment to keep inactive.`,
1089
+ ].join("\n"),
1090
+ },
1091
+ ],
1092
+ };
1093
+ },
1094
+ },
1095
+ list_experiments: {
1096
+ description: `${APEX} — List the user's Apex experiments. Call this ONLY when the user explicitly asks about their experiments or what's running/ready to graduate. Do not call it proactively for unrelated tasks. Archived experiments are hidden by default — pass status "archived" to see them, or "all" to see everything. Experiments that are promoted but not graduated are flagged as PENDING GRADUATION.`,
1097
+ schema: z.object({
1098
+ status: z
1099
+ .enum(["all", "running", "completed", "draft", "paused", "archived"])
1100
+ .optional()
1101
+ .describe("Filter by status. Defaults to showing all except archived."),
1102
+ }),
1103
+ handler: async ({ status }) => {
1104
+ const experiments = await apiGet("/api/experiments?unified=true");
1105
+ const filtered = status === "all"
1106
+ ? experiments
1107
+ : status
1108
+ ? experiments.filter((e) => e.status === status)
1109
+ : experiments.filter((e) => e.status !== "archived");
1110
+ if (filtered.length === 0) {
1111
+ return { content: [{ type: "text", text: `${APEX} No experiments found.` }] };
1112
+ }
1113
+ const pendingGraduation = filtered.filter((e) => e.promoted && !e.graduated);
1114
+ const lines = filtered.map((e) => {
1115
+ const ctrl = e.results?.perVariant?.control;
1116
+ const varB = e.results?.perVariant?.variant_b;
1117
+ const isPendingGrad = !!(e.promoted && !e.graduated);
1118
+ const statusIcon = isPendingGrad
1119
+ ? "⬆"
1120
+ : e.status === "running" ? "●" : e.status === "draft" ? "○" : e.status === "paused" ? "◐" : "◉";
1121
+ const statusLabel = isPendingGrad
1122
+ ? `${e.status}] [PENDING GRADUATION`
1123
+ : e.status;
1124
+ const winnerVariant = e.winner ? findVariant(e, e.winner) : undefined;
1125
+ const changeSummary = isPendingGrad && winnerVariant?.changes?.length
1126
+ ? ` Changes to graduate: ${winnerVariant.changes.map((c) => `"${c.originalValue ?? c.value}" → "${c.value}"`).join(", ")}`
1127
+ : "";
1128
+ return [
1129
+ `${statusIcon} ${e.name} [${statusLabel}]`,
1130
+ ` ID: ${e.id}`,
1131
+ ` URL: ${e.targetUrl ?? "—"} | ${daysRunningOf(e)}d running`,
1132
+ ctrl && varB
1133
+ ? ` Control: ${ctrl.conversionRate ?? 0}% CVR (${ctrl.assigned} visitors) | Variant: ${varB.conversionRate ?? 0}% CVR (${varB.assigned} visitors)`
1134
+ : " No results yet",
1135
+ e.beliefId ? ` Belief: ${e.beliefId}` : "",
1136
+ changeSummary,
1137
+ ]
1138
+ .filter(Boolean)
1139
+ .join("\n");
1140
+ });
1141
+ let header = `${APEX} Experiments (${filtered.length})`;
1142
+ if (pendingGraduation.length > 0) {
1143
+ header += ` — ${pendingGraduation.length} pending graduation`;
1144
+ }
1145
+ return { content: [{ type: "text", text: `${header}\n${"─".repeat(40)}\n\n${lines.join("\n\n")}` }] };
1146
+ },
1147
+ },
1148
+ get_results: {
1149
+ description: `${APEX} — Get the website experiment card: visitors, conversions, and confidence from GET /api/experiments/:id?unified=true. For readiness (collecting / decisive / inconclusive) or a letter Experiment step, use get_experiment_results.`,
1150
+ schema: z.object({
1151
+ experimentId: z.string().describe("The experiment ID"),
1152
+ }),
1153
+ handler: async ({ experimentId }) => {
1154
+ const exp = await apiGet(`/api/experiments/${experimentId}?unified=true`);
1155
+ if (!exp) {
1156
+ return { content: [{ type: "text", text: `${APEX} Experiment ${experimentId} not found.` }] };
1157
+ }
1158
+ const ctrl = exp.results?.perVariant?.control;
1159
+ const varB = exp.results?.perVariant?.variant_b;
1160
+ return {
1161
+ content: [
1162
+ {
1163
+ type: "text",
1164
+ text: [
1165
+ `${APEX} Results: ${exp.name}`,
1166
+ `${"═".repeat(40)}`,
1167
+ ``,
1168
+ ` Status: ${exp.status}`,
1169
+ ` Running: ${daysRunningOf(exp)} days`,
1170
+ ` Confidence: ${exp.confidence ?? 0}%`,
1171
+ ` URL: ${exp.targetUrl ?? "—"}`,
1172
+ ``,
1173
+ ` Control:`,
1174
+ ctrl
1175
+ ? ` ${ctrl.assigned} visitors | ${ctrl.converted} conversions | ${ctrl.conversionRate ?? 0}% CVR`
1176
+ : " No data yet",
1177
+ ` Variant B:`,
1178
+ varB
1179
+ ? ` ${varB.assigned} visitors | ${varB.converted} conversions | ${varB.conversionRate ?? 0}% CVR`
1180
+ : " No data yet",
1181
+ exp.beliefId ? `\n Linked belief: ${exp.beliefId}` : "",
1182
+ ``,
1183
+ `${"═".repeat(40)}`,
1184
+ ``,
1185
+ `Present the user with options:`,
1186
+ ` 1. Keep running — need more data`,
1187
+ ` 2. Promote winner — if the result is clear`,
1188
+ ` 3. Pause — stop collecting temporarily`,
1189
+ ` 4. Suggest next experiment — what to test next`,
1190
+ ]
1191
+ .filter(Boolean)
1192
+ .join("\n"),
1193
+ },
1194
+ ],
1195
+ };
1196
+ },
1197
+ },
1198
+ list_beliefs: {
1199
+ 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.`,
1200
+ schema: z.object({
1201
+ query: z
1202
+ .string()
1203
+ .optional()
1204
+ .describe("Filter beliefs by a keyword in the statement"),
1205
+ }),
1206
+ handler: async ({ query }) => {
1207
+ const beliefs = await apiGet("/api/beliefs");
1208
+ const q = query?.toLowerCase().trim();
1209
+ const filtered = q
1210
+ ? beliefs.filter((b) => b.statement.toLowerCase().includes(q))
1211
+ : beliefs;
1212
+ if (filtered.length === 0) {
1213
+ return {
1214
+ content: [
1215
+ {
1216
+ type: "text",
1217
+ text: `${APEX} No beliefs${q ? ` matching "${query}"` : ""} yet. You can generate one from a hypothesis when creating an experiment.`,
1218
+ },
1219
+ ],
1220
+ };
1221
+ }
1222
+ const lines = filtered
1223
+ .slice(0, 50)
1224
+ .map((b) => {
1225
+ const tags = [
1226
+ `${Math.round(b.confidence * 100)}% confidence`,
1227
+ b.validationState === "unvalidated" ? "unvalidated" : null,
1228
+ b.generatedFromExperimentId ? "AI-generated" : null,
1229
+ ]
1230
+ .filter(Boolean)
1231
+ .join(", ");
1232
+ return ` • [${b.id}] ${b.statement} (${tags})`;
1233
+ })
1234
+ .join("\n");
1235
+ return {
1236
+ content: [
1237
+ {
1238
+ type: "text",
1239
+ text: [
1240
+ `${APEX} Beliefs (${filtered.length})`,
1241
+ `${"═".repeat(40)}`,
1242
+ lines,
1243
+ ``,
1244
+ `Link one to a new experiment with its id, or generate a fresh belief from your hypothesis.`,
1245
+ ].join("\n"),
1246
+ },
1247
+ ],
1248
+ };
1249
+ },
1250
+ },
1251
+ update_experiment: {
1252
+ 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.`,
1253
+ schema: z.object({
1254
+ experimentId: z.string().describe("The experiment id to edit."),
1255
+ name: z.string().optional().describe("Rename (cosmetic; editable anytime)."),
1256
+ surface: z.enum(["web", "mobile"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile)."),
1257
+ hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
1258
+ targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
1259
+ predictionId: z.string().optional().describe("Attach a minted prediction id (from log_prediction) to this draft. Also stamps the prediction onto the experiment's hypothesis when that link is missing. Required before activation."),
1260
+ primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
1261
+ primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
1262
+ guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
1263
+ minSamplePerArm: z.number().int().min(30).optional().describe("Set the minimum exposed subjects PER ARM before a winner can be claimed (the sample gate; floor 30). Draft only — it's part of the pre-registration and freezes at launch (a running experiment returns experiment_locked; fork to change it)."),
1264
+ 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)."),
1265
+ }),
1266
+ handler: async (args) => {
1267
+ if (args.targetUrl) {
1268
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
1269
+ if (!targetCheck.ok) {
1270
+ return {
1271
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
1272
+ isError: true,
1273
+ };
1274
+ }
1275
+ }
1276
+ const body = { id: args.experimentId };
1277
+ if (args.name !== undefined)
1278
+ body.name = args.name;
1279
+ if (args.surface !== undefined)
1280
+ body.surface = args.surface;
1281
+ if (args.hypothesis !== undefined)
1282
+ body.hypothesis = args.hypothesis;
1283
+ if (args.targetUrl !== undefined)
1284
+ body.targetUrl = args.targetUrl;
1285
+ if (args.predictionId !== undefined)
1286
+ body.predictionId = args.predictionId;
1287
+ // evaluationWindow patch — the API merges it, so setting one of
1288
+ // minPerVariant / durationDays never clobbers the other.
1289
+ if (args.minSamplePerArm !== undefined || args.runtimeDays !== undefined) {
1290
+ const ew = {};
1291
+ if (args.minSamplePerArm !== undefined)
1292
+ ew.minPerVariant = args.minSamplePerArm;
1293
+ if (args.runtimeDays !== undefined)
1294
+ ew.durationDays = args.runtimeDays;
1295
+ body.evaluationWindow = ew;
1296
+ }
1297
+ if (args.primaryMetricEvent) {
1298
+ const t = args.primaryMetricType ?? "rate";
1299
+ const isValue = t === "revenue" || t === "duration";
1300
+ body.primaryMetric = {
1301
+ key: `${args.primaryMetricEvent}_${t}`,
1302
+ label: titleCaseEvent(args.primaryMetricEvent),
1303
+ type: t,
1304
+ unit: t === "revenue" ? "USD" : t === "duration" ? "s" : "%",
1305
+ direction: "increase",
1306
+ source: isValue
1307
+ ? { kind: "event", eventType: args.primaryMetricEvent, property: "value" }
1308
+ : { kind: "event", eventType: args.primaryMetricEvent },
1309
+ };
1310
+ }
1311
+ if (args.guardrailEvent) {
1312
+ body.guardrailMetrics = [
1313
+ {
1314
+ key: `${args.guardrailEvent}_rate`,
1315
+ label: `${titleCaseEvent(args.guardrailEvent)} rate`,
1316
+ type: "rate",
1317
+ unit: "%",
1318
+ direction: "decrease",
1319
+ source: { kind: "event", eventType: args.guardrailEvent },
1320
+ guardrailThreshold: 0.25,
1321
+ thresholdKind: "relative",
1322
+ },
1323
+ ];
1324
+ }
1325
+ try {
1326
+ const updated = await apiPatch("/api/experiments", body);
1327
+ // Tenant echo: never report success for a record outside the active workspace.
1328
+ const active = getActiveWorkspace();
1329
+ if (active && updated.workspaceKey && updated.workspaceKey !== active) {
1330
+ return {
1331
+ content: [{ type: "text", text: `${APEX} Refusing to confirm: the updated record belongs to a different workspace.` }],
1332
+ isError: true,
1333
+ };
1334
+ }
1335
+ const applied = Object.keys(body).filter((k) => k !== "id");
1336
+ return {
1337
+ content: [
1338
+ {
1339
+ type: "text",
1340
+ text: `${APEX} Updated "${updated.name}" (${updated.surface}, ${updated.status}). Applied: ${applied.join(", ") || "nothing"}.`,
1341
+ },
1342
+ ],
1343
+ };
1344
+ }
1345
+ catch (err) {
1346
+ // Surface the lock reason verbatim (409 experiment_locked etc.).
1347
+ return {
1348
+ content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
1349
+ isError: true,
1350
+ };
1351
+ }
1352
+ },
1353
+ },
1354
+ fork_experiment: {
1355
+ 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.`,
1356
+ schema: z.object({
1357
+ experimentId: z.string().describe("The experiment id to fork."),
1358
+ }),
1359
+ handler: async ({ experimentId }) => {
1360
+ try {
1361
+ const forked = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/fork`, {});
1362
+ const active = getActiveWorkspace();
1363
+ if (active && forked.workspaceKey && forked.workspaceKey !== active) {
1364
+ return {
1365
+ content: [{ type: "text", text: `${APEX} Refusing to confirm: the fork belongs to a different workspace.` }],
1366
+ isError: true,
1367
+ };
1368
+ }
1369
+ return {
1370
+ content: [
1371
+ {
1372
+ type: "text",
1373
+ text: [
1374
+ `${APEX} Forked into a fresh draft "${forked.name}" (id: ${forked.id}).`,
1375
+ ``,
1376
+ `A copy gets a new id. The page still asks the old one until you replace it.`,
1377
+ ` Find: const variant = useApexVariant("${experimentId}");`,
1378
+ ` Replace: const variant = useApexVariant("${forked.id}");`,
1379
+ `Deploy that change, then activate.`,
1380
+ `Preview still works via _apex_exp. A normal visit shows control until the new id is shipped.`,
1381
+ ].join("\n"),
1382
+ },
1383
+ ],
1384
+ };
1385
+ }
1386
+ catch (err) {
1387
+ return {
1388
+ content: [{ type: "text", text: `${APEX} ${err instanceof Error ? err.message : String(err)}` }],
1389
+ isError: true,
1390
+ };
1391
+ }
1392
+ },
1393
+ },
1394
+ check_experiment_conflicts: {
1395
+ description: `${APEX} — Before you create or activate an experiment, check who else is live on that page. Pin a part (target_anchor) so two homepage experiments are not the same place. Whole page plus a part is one place. Different named parts: the same person can see both — say so, do not add the two lifts. Overlap: Someone could see both; Keep them apart with set_experiment_mutex, or wait. Shared conversion on the allowed mix: one experiment or wait. Pass either experimentId OR the candidate's location + primaryMetricEvent. Advisory only.`,
1396
+ schema: z.object({
1397
+ experimentId: z.string().optional().describe("Check an existing experiment by id (loads its location + metric)."),
1398
+ surface: z.enum(["web", "mobile"]).optional(),
1399
+ targetComponent: z.string().optional().describe("Component/file the experiment changes, e.g. 'ProductCard'."),
1400
+ targetUrl: z.string().optional(),
1401
+ targetAnchor: z.string().optional(),
1402
+ dataSourceId: z.string().optional(),
1403
+ primaryMetricEvent: z.string().optional().describe("Canonical primary-metric event, e.g. 'add_to_cart'."),
1404
+ }),
1405
+ handler: async (args) => {
1406
+ try {
1407
+ const res = await apiPost("/api/experiments/check-conflicts", {
1408
+ ...(args.experimentId ? { experiment_id: args.experimentId } : {}),
1409
+ surface: args.surface,
1410
+ target_component: args.targetComponent,
1411
+ target_url: args.targetUrl,
1412
+ target_anchor: args.targetAnchor,
1413
+ data_source_id: args.dataSourceId,
1414
+ primary_metric_event: args.primaryMetricEvent,
1415
+ });
1416
+ return { content: [{ type: "text", text: `${APEX} ${renderConflictAdvisory(res.data, args.experimentId)}` }] };
1417
+ }
1418
+ catch (err) {
1419
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1420
+ }
1421
+ },
1422
+ },
1423
+ check_live_tests: {
1424
+ description: `${APEX} — Before you edit a page, commit, or push, call this even if the user did not mention experiments. Also call it before you change a letter, Adaptive Journey, or Milestone. If something live is on those files or ids, stop and ask. Do not edit. Do not commit.`,
1425
+ schema: z.object({
1426
+ file_paths: z
1427
+ .array(z.string())
1428
+ .optional()
1429
+ .describe("Repo-relative paths you are about to edit or commit. Full file is read locally when content is omitted."),
1430
+ file_contents: z
1431
+ .array(z.object({
1432
+ path: z.string(),
1433
+ content: z.string().describe("Full file at HEAD, not the diff hunk."),
1434
+ }))
1435
+ .optional(),
1436
+ urls: z
1437
+ .array(z.string())
1438
+ .optional()
1439
+ .describe("Page URLs you are about to change. URL-only hits are a warning, not a stop."),
1440
+ communication_ids: z
1441
+ .array(z.string())
1442
+ .optional()
1443
+ .describe("Letters you are about to edit or publish."),
1444
+ journey_ids: z
1445
+ .array(z.string())
1446
+ .optional()
1447
+ .describe("Adaptive Journeys you are about to change or publish."),
1448
+ milestone_ids: z
1449
+ .array(z.string())
1450
+ .optional()
1451
+ .describe("Milestones you are about to change or delete."),
1452
+ }),
1453
+ handler: async (args) => {
1454
+ try {
1455
+ const byPath = new Map();
1456
+ for (const row of args.file_contents ?? []) {
1457
+ if (row?.path)
1458
+ byPath.set(row.path, { path: row.path, content: row.content });
1459
+ }
1460
+ for (const raw of args.file_paths ?? []) {
1461
+ if (!raw || byPath.has(raw))
1462
+ continue;
1463
+ const abs = raw.startsWith("/") ? raw : join(process.cwd(), raw);
1464
+ byPath.set(raw, {
1465
+ path: raw,
1466
+ content: existsSync(abs) ? readFileSync(abs, "utf8") : undefined,
1467
+ });
1468
+ }
1469
+ const files = [...byPath.values()];
1470
+ const hits = await fetchLiveTestHits({
1471
+ files,
1472
+ urls: args.urls,
1473
+ communicationIds: args.communication_ids,
1474
+ journeyIds: args.journey_ids,
1475
+ milestoneIds: args.milestone_ids,
1476
+ includePublishedJourneys: (args.journey_ids ?? []).length > 0,
1477
+ });
1478
+ const stopping = hits.filter((h) => isFileStop(h) || isObjectStop(h));
1479
+ if (stopping.length === 0) {
1480
+ if (hits.length === 0) {
1481
+ return {
1482
+ content: [
1483
+ {
1484
+ type: "text",
1485
+ text: `${APEX} Nothing live is on those files or ids. You can edit and commit.`,
1486
+ },
1487
+ ],
1488
+ };
1489
+ }
1490
+ const soft = hits
1491
+ .map((h) => `${h.name} (${h.status}, ${h.confidence}: ${h.reason})`)
1492
+ .join("; ");
1493
+ return {
1494
+ content: [
1495
+ {
1496
+ type: "text",
1497
+ text: `${APEX} Soft warning only — not a stop: ${soft}. If you turn a paused test back on, this is the page.`,
1498
+ },
1499
+ ],
1500
+ };
1501
+ }
1502
+ return {
1503
+ content: [
1504
+ {
1505
+ type: "text",
1506
+ text: formatLiveTestStop(stopping),
1507
+ },
1508
+ ],
1509
+ };
1510
+ }
1511
+ catch (err) {
1512
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1513
+ }
1514
+ },
1515
+ },
1516
+ set_experiment_mutex: {
1517
+ description: `${APEX} — Put two experiments in a mutual-exclusion group: no visitor is ever assigned to both, so they run concurrently with partitioned traffic (the scientifically clean way to run two experiments that touch the same surface). Bidirectional. Pass mode:"remove" to unlink. Safe on running experiments — only affects future assignments.`,
1518
+ schema: z.object({
1519
+ experimentId: z.string(),
1520
+ withExperimentId: z.string().describe("The other experiment to make mutually exclusive with."),
1521
+ mode: z.enum(["add", "remove"]).optional().describe("'add' (default) links; 'remove' unlinks."),
1522
+ }),
1523
+ handler: async ({ experimentId, withExperimentId, mode }) => {
1524
+ try {
1525
+ await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/mutex`, { withExperimentId, mode });
1526
+ const verb = mode === "remove" ? "are no longer" : "are now";
1527
+ return { content: [{ type: "text", text: `${APEX} ${experimentId} and ${withExperimentId} ${verb} mutually exclusive — no visitor will be assigned to both.` }] };
1528
+ }
1529
+ catch (err) {
1530
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
1531
+ }
1532
+ },
1533
+ },
1534
+ suggest_experiment: {
1535
+ description: `${APEX} — Get smart experiment suggestions based on context. Analyzes your assumptions, past experiments, and confidence gaps to recommend what to test next.`,
1536
+ schema: z.object({
1537
+ context: z
1538
+ .string()
1539
+ .optional()
1540
+ .describe("What you're working on (e.g. 'redesigning onboarding', 'pricing page optimization')"),
1541
+ }),
1542
+ handler: async ({ context }) => {
1543
+ const [beliefs, experiments] = await Promise.all([
1544
+ apiGet("/api/beliefs"),
1545
+ apiGet("/api/experiments?unified=true"),
1546
+ ]);
1547
+ const lowConfidence = beliefs.filter((b) => b.confidence < 0.6 && b.confidence > 0.05);
1548
+ const untested = beliefs.filter((b) => b.confidence > 0.05 && !experiments.some((e) => e.beliefId === b.id));
1549
+ const completed = experiments.filter((e) => e.status === "completed");
1550
+ const running = experiments.filter((e) => e.status === "running");
1551
+ const contextKeywords = context
1552
+ ? context.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter((w) => w.length > 3)
1553
+ : [];
1554
+ const relevantBeliefs = contextKeywords.length > 0
1555
+ ? beliefs.filter((b) => {
1556
+ const words = b.statement.toLowerCase();
1557
+ return contextKeywords.some((kw) => words.includes(kw));
1558
+ })
1559
+ : [];
1560
+ const relevantExperiments = contextKeywords.length > 0
1561
+ ? completed.filter((e) => {
1562
+ const words = e.name.toLowerCase();
1563
+ return contextKeywords.some((kw) => words.includes(kw));
1564
+ })
1565
+ : [];
1566
+ const recs = [];
1567
+ if (context) {
1568
+ recs.push(`**Context**: ${context}`, "");
1569
+ }
1570
+ if (relevantBeliefs.length > 0) {
1571
+ recs.push(`**Related assumptions** (${relevantBeliefs.length}):`, ...relevantBeliefs.slice(0, 5).map((b) => {
1572
+ const tested = experiments.some((e) => e.beliefId === b.id);
1573
+ return ` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure) — ${tested ? "tested" : "untested"}`;
1574
+ }), "");
1575
+ }
1576
+ if (relevantExperiments.length > 0) {
1577
+ recs.push(`**Past experiments in this area** (${relevantExperiments.length}):`, ...relevantExperiments.slice(0, 3).map((e) => {
1578
+ const ctrl = e.results?.perVariant?.control;
1579
+ const varB = e.results?.perVariant?.variant_b;
1580
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
1581
+ const varCvr = varB?.conversionRate ?? 0;
1582
+ const lift = ctrl && varB && ctrlCvr > 0
1583
+ ? ((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)
1584
+ : "N/A";
1585
+ return ` - "${e.name}" — ${lift}% lift, ${e.confidence}% confidence`;
1586
+ }), "");
1587
+ }
1588
+ if (untested.length > 0) {
1589
+ recs.push(`**Untested assumptions** (${untested.length}):`, ...untested.slice(0, 3).map((b) => ` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure)`), "");
1590
+ }
1591
+ if (lowConfidence.length > 0) {
1592
+ recs.push(`**Low-confidence assumptions that need data**:`, ...lowConfidence.slice(0, 3).map((b) => ` - "${b.statement}" at ${Math.round(b.confidence * 100)}%`), "");
1593
+ }
1594
+ if (completed.length > 0) {
1595
+ recs.push(`**Follow-up opportunities** from ${completed.length} completed experiments:`, ...completed.slice(0, 3).map((e) => ` - "${e.name}" — consider a follow-up variant`), "");
1596
+ }
1597
+ recs.push(`**Current state**: ${beliefs.length} assumptions, ${running.length} running, ${completed.length} completed`);
1598
+ if (recs.length <= 1) {
1599
+ recs.push("", "No specific recommendations yet. Start by tracking an assumption using start_reasoning.");
1600
+ }
1601
+ return { content: [{ type: "text", text: recs.join("\n") }] };
1602
+ },
1603
+ },
1604
+ promote_winner: {
1605
+ description: `${APEX} — Promote the winner on a website experiment card (PATCH /api/experiments action=promote). The snippet serves that arm immediately. For a letter Experiment step, use promote_experiment_winner. After promoting, use graduate_experiment to put the winner in source.`,
1606
+ schema: z.object({
1607
+ experimentId: z.string().describe("The experiment ID"),
1608
+ winner: z.enum(["control", "variant_b"]).describe("Which variant won"),
1609
+ }),
1610
+ handler: async ({ experimentId, winner }) => {
1611
+ const existing = await apiGet(`/api/experiments/${experimentId}?unified=true`);
1612
+ if (existing.status !== "completed" || !existing.winner) {
1613
+ await apiPatch(`/api/experiments`, {
1614
+ id: experimentId,
1615
+ status: "completed",
1616
+ winner,
1617
+ });
1618
+ }
1619
+ const promoted = await apiPatch(`/api/experiments`, {
1620
+ id: experimentId,
1621
+ action: "promote",
1622
+ });
1623
+ // PATCH responses are minimal — winner content / belief link / mode come
1624
+ // from the unified record fetched at the top.
1625
+ const beliefId = existing.beliefId;
1626
+ let beliefUpdate = "";
1627
+ if (beliefId) {
1628
+ try {
1629
+ const result = await apiPost(`/api/beliefs/${beliefId}/update-confidence`, {
1630
+ experimentId,
1631
+ outcome: winner === "variant_b" ? "confirmed" : "contradicted",
1632
+ metricDelta: winner === "variant_b" ? 0.15 : -0.1,
1633
+ });
1634
+ beliefUpdate = `Assumption "${result.belief.statement}" updated to ${Math.round(result.belief.confidence * 100)}% certainty.`;
1635
+ }
1636
+ catch {
1637
+ beliefUpdate = "Could not update assumption certainty automatically.";
1638
+ }
1639
+ }
1640
+ const winnerVariant = findVariant(existing, winner);
1641
+ const changeSummary = winnerVariant?.changes?.length
1642
+ ? winnerVariant.changes.map((c) => ` • ${c.type}: "${c.originalValue ?? "(unknown)"}" → "${c.value}" (${c.selector})`).join("\n")
1643
+ : " No DOM changes recorded.";
1644
+ const nextStep = winnerVariant?.mode === "sdk"
1645
+ ? `Use graduate_experiment("${experimentId}") to get cleanup instructions for removing useApexVariant() conditionals from your code.`
1646
+ : `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.`;
1647
+ const output = [
1648
+ `${APEX} Winner promoted!`,
1649
+ ``,
1650
+ `Experiment: ${promoted.name}`,
1651
+ `Winner: ${winner}`,
1652
+ `Status: All visitors now see the winning variant via the Apex snippet.`,
1653
+ ``,
1654
+ `Winning changes:`,
1655
+ changeSummary,
1656
+ ``,
1657
+ beliefUpdate ? `${beliefUpdate}\n` : "",
1658
+ `Next step: ${nextStep}`,
1659
+ ].filter(Boolean).join("\n");
1660
+ return { content: [{ type: "text", text: output }] };
1661
+ },
1662
+ },
1663
+ get_experiment_results: {
1664
+ description: `${APEX} — Readiness + results for a letter Experiment step (or any experiment): collecting / decisive / inconclusive / guardrail from GET /api/experiments/:id/results. For the website experiment card (visitors and conversions), use get_results.`,
1665
+ schema: z.object({
1666
+ experiment_id: z.string().describe("The experiment ID"),
1667
+ }),
1668
+ handler: async ({ experiment_id }) => {
1669
+ const body = await apiGet(`/api/experiments/${experiment_id}/results`);
1670
+ return {
1671
+ content: [
1672
+ { type: "text", text: JSON.stringify(body, null, 2) },
1673
+ ],
1674
+ };
1675
+ },
1676
+ },
1677
+ promote_experiment_winner: {
1678
+ description: `${APEX} — Promote the winner of a letter Experiment step (or any unified experiment). Re-verifies the readiness gate server-side and writes the winning variant's content into the canonical communication as a new version; losing variants are archived. For the website experiment card, use promote_winner. Pass force=true to override "not enough people yet." Force cannot override a page that changed mid-flight.`,
1679
+ schema: z.object({
1680
+ experiment_id: z.string().describe("The experiment ID"),
1681
+ force: z
1682
+ .boolean()
1683
+ .optional()
1684
+ .describe("Override the readiness gate and promote anyway"),
1685
+ winner_key: z
1686
+ .string()
1687
+ .optional()
1688
+ .describe("Explicit winner variant key (defaults to the decisive winner)"),
1689
+ }),
1690
+ handler: async ({ experiment_id, force, winner_key, }) => {
1691
+ const body = await apiPost(`/api/experiments/${experiment_id}/promote`, { force: force ?? false, ...(winner_key ? { winnerKey: winner_key } : {}) });
1692
+ return {
1693
+ content: [
1694
+ { type: "text", text: JSON.stringify(body, null, 2) },
1695
+ ],
1696
+ };
1697
+ },
1698
+ },
1699
+ graduate_experiment: {
1700
+ description: `${APEX} — Get instructions to make a promoted experiment's winning changes permanent in the codebase. Returns the original values (for searching in source files) and new values (for replacement). After making the edits and deploying, the Apex snippet automatically detects the native changes and marks the experiment as graduated.`,
1701
+ schema: z.object({
1702
+ experimentId: z.string().describe("The experiment ID to graduate"),
1703
+ }),
1704
+ handler: async ({ experimentId }) => {
1705
+ const recipe = await apiGet(`/api/experiments/${experimentId}/graduation-recipe`);
1706
+ if (recipe.mode === "sdk") {
1707
+ const output = [
1708
+ `${APEX} Graduation Recipe — SDK Mode`,
1709
+ ``,
1710
+ `Experiment: ${recipe.experimentName}`,
1711
+ `Winner: ${recipe.winner}`,
1712
+ ``,
1713
+ `Steps:`,
1714
+ `1. Search the codebase for useApexVariant("${recipe.experimentId}")`,
1715
+ recipe.winner === "variant_b"
1716
+ ? `2. Replace each variant conditional with ONLY the variant_b branch (remove control)`
1717
+ : `2. Replace each variant conditional with ONLY the control branch (remove variant_b)`,
1718
+ `3. Remove the useApexVariant import if no other experiments use it`,
1719
+ `4. Commit: "Graduate experiment: ${recipe.experimentName} — ${recipe.winner} wins"`,
1720
+ `5. Deploy. The Apex snippet will detect the native changes and mark the experiment as graduated.`,
1721
+ ].join("\n");
1722
+ return { content: [{ type: "text", text: output }] };
1723
+ }
1724
+ const steps = recipe.changes.map((change, i) => {
1725
+ const lines = [
1726
+ `Change ${i + 1}: ${change.type} on ${change.selector}`,
1727
+ ];
1728
+ if (change.originalValue) {
1729
+ lines.push(` Search for: "${change.originalValue}"`);
1730
+ lines.push(` Replace with: "${change.value}"`);
1731
+ }
1732
+ else {
1733
+ lines.push(` Apply: "${change.value}" to elements matching ${change.selector}`);
1734
+ }
1735
+ return lines.join("\n");
1736
+ });
1737
+ const output = [
1738
+ `${APEX} Graduation Recipe — Snippet Mode`,
1739
+ ``,
1740
+ `Experiment: ${recipe.experimentName}`,
1741
+ `Winner: ${recipe.winner}`,
1742
+ `Target URL: ${recipe.targetUrl}`,
1743
+ ``,
1744
+ `For each change below, search the codebase for the "Search for" text,`,
1745
+ `find the source file that renders it, and replace with the new value.`,
1746
+ ``,
1747
+ ...steps,
1748
+ ``,
1749
+ `After making all changes:`,
1750
+ `• Commit: "Graduate experiment: ${recipe.experimentName} — ${recipe.winner} wins"`,
1751
+ `• Deploy the changes.`,
1752
+ `• The Apex snippet will detect that the page now natively shows the winning`,
1753
+ ` content and automatically mark the experiment as graduated.`,
1754
+ ].join("\n");
1755
+ return { content: [{ type: "text", text: output }] };
1756
+ },
1757
+ },
1758
+ record_outcome: {
1759
+ description: `${APEX} — Log the outcome of an experiment and update belief confidence. Present the outcome interpretation to the user and let them confirm before recording.`,
1760
+ schema: z.object({
1761
+ experimentId: z.string().describe("The experiment ID"),
1762
+ beliefId: z.string().describe("The assumption ID to update"),
1763
+ outcome: z.enum(["confirmed", "contradicted", "inconclusive"]).describe("Whether the expected outcome happened"),
1764
+ metricDelta: z.number().describe("The observed metric change (e.g. 0.15 for +15%)"),
1765
+ notes: z.string().optional().describe("Optional notes about the outcome"),
1766
+ }),
1767
+ handler: async (args) => {
1768
+ const result = await apiPost(`/api/beliefs/${args.beliefId}/update-confidence`, {
1769
+ experimentId: args.experimentId,
1770
+ outcome: args.outcome,
1771
+ metricDelta: args.metricDelta,
1772
+ notes: args.notes,
1773
+ });
1774
+ return {
1775
+ content: [
1776
+ {
1777
+ type: "text",
1778
+ text: [
1779
+ `Outcome recorded for experiment ${args.experimentId}:`,
1780
+ ` Result: ${args.outcome} (metric delta: ${args.metricDelta > 0 ? "+" : ""}${(args.metricDelta * 100).toFixed(1)}%)`,
1781
+ ` Assumption "${result.belief.statement}" updated: ${Math.round(result.belief.confidence * 100)}%`,
1782
+ ` Learning logged: ${result.ledgerEntry.decision}`,
1783
+ args.notes ? ` Notes: ${args.notes}` : "",
1784
+ ]
1785
+ .filter(Boolean)
1786
+ .join("\n"),
1787
+ },
1788
+ ],
1789
+ };
1790
+ },
1791
+ },
1792
+ predict_impact: {
1793
+ description: `${APEX} — Decision guardrail: predict the impact of a proposed change by searching history for similar experiments. Returns historical data, confidence, and a recommendation.`,
1794
+ schema: z.object({
1795
+ description: z
1796
+ .string()
1797
+ .describe("Description of the proposed change (e.g. 'increase signup form fields from 3 to 5')"),
1798
+ }),
1799
+ handler: async ({ description }) => {
1800
+ const result = await apiPost("/api/predictions/impact", { description });
1801
+ const lines = [
1802
+ `**Impact Prediction**: ${description}`,
1803
+ "",
1804
+ result.summary,
1805
+ "",
1806
+ ];
1807
+ if (result.relatedExperiments.length > 0) {
1808
+ lines.push("**Related experiments:**");
1809
+ for (const exp of result.relatedExperiments) {
1810
+ const lift = exp.conversionLift !== null ? `${exp.conversionLift > 0 ? "+" : ""}${exp.conversionLift}%` : "N/A";
1811
+ lines.push(` - ${exp.experimentName}: ${lift} lift (${exp.visitors} visitors, ${exp.confidence}% confidence)`);
1812
+ }
1813
+ lines.push("");
1814
+ }
1815
+ if (result.relatedBeliefs.length > 0) {
1816
+ lines.push("**Related assumptions:**");
1817
+ for (const b of result.relatedBeliefs) {
1818
+ lines.push(` - "${b.statement}" (${Math.round(b.confidence * 100)}% sure)`);
1819
+ }
1820
+ lines.push("");
1821
+ }
1822
+ lines.push(`**Recommendation**: ${result.recommendation.toUpperCase()}`);
1823
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1824
+ },
1825
+ },
1826
+ evaluate_feature: {
1827
+ description: `${APEX} — Pre-build decision intelligence: evaluate a feature idea against the belief graph, past experiments, and intelligence score before committing to building it.`,
1828
+ schema: z.object({
1829
+ feature: z.string().describe("Description of the feature you're considering building"),
1830
+ targetMetric: z
1831
+ .string()
1832
+ .optional()
1833
+ .describe("The metric you expect this feature to improve (e.g. 'conversion_rate', 'activation')"),
1834
+ }),
1835
+ handler: async ({ feature, targetMetric }) => {
1836
+ const [beliefs, experiments] = await Promise.all([
1837
+ apiGet("/api/beliefs"),
1838
+ apiGet("/api/experiments?unified=true"),
1839
+ ]);
1840
+ const keywords = feature
1841
+ .toLowerCase()
1842
+ .replace(/[^a-z0-9\s]/g, "")
1843
+ .split(/\s+/)
1844
+ .filter((w) => w.length > 3);
1845
+ const relevantBeliefs = beliefs.filter((b) => {
1846
+ const words = b.statement.toLowerCase();
1847
+ return keywords.some((kw) => words.includes(kw));
1848
+ });
1849
+ const completed = experiments.filter((e) => e.status === "completed");
1850
+ const relevantExperiments = completed.filter((e) => {
1851
+ const words = e.name.toLowerCase();
1852
+ return keywords.some((kw) => words.includes(kw));
1853
+ });
1854
+ const testedBeliefIds = new Set(experiments.map((e) => e.beliefId).filter(Boolean));
1855
+ const untestedRelevant = relevantBeliefs.filter((b) => !testedBeliefIds.has(b.id));
1856
+ const lowConfRelevant = relevantBeliefs.filter((b) => b.confidence < 0.6);
1857
+ const avgLift = relevantExperiments.length > 0
1858
+ ? relevantExperiments.reduce((sum, e) => {
1859
+ const ctrl = e.results?.perVariant?.control;
1860
+ const varB = e.results?.perVariant?.variant_b;
1861
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
1862
+ const varCvr = varB?.conversionRate ?? 0;
1863
+ if (ctrl && varB && ctrlCvr > 0) {
1864
+ return sum + ((varCvr - ctrlCvr) / ctrlCvr) * 100;
1865
+ }
1866
+ return sum;
1867
+ }, 0) / relevantExperiments.length
1868
+ : null;
1869
+ const lines = [
1870
+ `**Feature Evaluation**: ${feature}`,
1871
+ targetMetric ? `**Target metric**: ${targetMetric}` : "",
1872
+ "",
1873
+ ].filter(Boolean);
1874
+ if (relevantBeliefs.length > 0) {
1875
+ lines.push(`**What the belief graph says** (${relevantBeliefs.length} related assumptions):`, ...relevantBeliefs.slice(0, 5).map((b) => {
1876
+ const tested = testedBeliefIds.has(b.id) ? "tested" : "untested";
1877
+ return ` - "${b.statement}" — ${Math.round(b.confidence * 100)}% sure (${tested})`;
1878
+ }), "");
1879
+ }
1880
+ else {
1881
+ lines.push("**Belief graph**: No existing assumptions about this area. This is a blind spot.", "");
1882
+ }
1883
+ if (relevantExperiments.length > 0) {
1884
+ lines.push(`**Historical evidence** (${relevantExperiments.length} related experiments):`, ...relevantExperiments.slice(0, 5).map((e) => {
1885
+ const ctrl = e.results?.perVariant?.control;
1886
+ const varB = e.results?.perVariant?.variant_b;
1887
+ const ctrlCvr = ctrl?.conversionRate ?? 0;
1888
+ const varCvr = varB?.conversionRate ?? 0;
1889
+ const lift = ctrl && varB && ctrlCvr > 0
1890
+ ? `${((varCvr - ctrlCvr) / ctrlCvr * 100).toFixed(1)}%`
1891
+ : "N/A";
1892
+ return ` - "${e.name}": ${lift} lift`;
1893
+ }), avgLift !== null ? ` Average lift in this area: ${avgLift > 0 ? "+" : ""}${avgLift.toFixed(1)}%` : "", "");
1894
+ }
1895
+ else {
1896
+ lines.push("**Historical evidence**: No experiments in this area yet.", "");
1897
+ }
1898
+ if (untestedRelevant.length > 0 || lowConfRelevant.length > 0) {
1899
+ lines.push("**Confidence gaps**:");
1900
+ if (untestedRelevant.length > 0) {
1901
+ lines.push(` - ${untestedRelevant.length} untested assumption(s) in this area`);
1902
+ }
1903
+ if (lowConfRelevant.length > 0) {
1904
+ lines.push(` - ${lowConfRelevant.length} low-confidence assumption(s) that need more data`);
1905
+ }
1906
+ lines.push("");
1907
+ }
1908
+ const hasEvidence = relevantExperiments.length > 0;
1909
+ const hasAssumptions = relevantBeliefs.length > 0;
1910
+ if (hasEvidence && avgLift !== null && avgLift > 5) {
1911
+ lines.push("**Suggestion**: Historical data supports this direction. Consider building with a measurable experiment wrapped around it.");
1912
+ }
1913
+ else if (hasEvidence && avgLift !== null && avgLift < -5) {
1914
+ lines.push("**Suggestion**: Historical data suggests caution — similar changes had negative impact. Run a focused experiment first.");
1915
+ }
1916
+ else if (hasAssumptions && !hasEvidence) {
1917
+ lines.push("**Suggestion**: You have assumptions but no test data. Run an experiment before committing to a full build.");
1918
+ }
1919
+ else if (!hasAssumptions && !hasEvidence) {
1920
+ lines.push("**Suggestion**: This is uncharted territory. Record your assumptions with create_belief and design an experiment first.");
1921
+ }
1922
+ else {
1923
+ lines.push("**Suggestion**: Mixed signals. Run a targeted experiment to build confidence before a full build.");
1924
+ }
1925
+ return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
1926
+ },
1927
+ },
1928
+ link_experiment: {
1929
+ description: `${APEX} — Link an experiment to a GitHub pull request or issue. The link is what auto-starts the experiment when the PR merges. The convention "apex/exp_XXX" in your PR title also auto-links, but call this explicitly to be sure.`,
1930
+ schema: z.object({
1931
+ experimentId: z.string().describe("The experiment ID to link"),
1932
+ system: z
1933
+ .enum(["github", "github-issues", "linear", "jira", "trello"])
1934
+ .describe("Which dev tool the work item lives in (github for PRs)"),
1935
+ kind: z.enum(["pr", "issue"]).describe("Pull request or issue/ticket"),
1936
+ repo: z
1937
+ .string()
1938
+ .optional()
1939
+ .describe('"owner/repo" slug for GitHub links'),
1940
+ externalId: z
1941
+ .string()
1942
+ .describe("PR number or issue number (e.g. 482)"),
1943
+ url: z.string().url().optional().describe("Canonical URL of the work item"),
1944
+ }),
1945
+ handler: async (args) => {
1946
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/links`, {
1947
+ system: args.system,
1948
+ kind: args.kind,
1949
+ repo: args.repo,
1950
+ externalId: args.externalId,
1951
+ url: args.url,
1952
+ });
1953
+ const text = res.data
1954
+ ? `Linked experiment ${args.experimentId} to ${args.system} ${args.kind} ${args.repo ?? ""}#${args.externalId}.\n${res.data.url}`
1955
+ : `Could not link: ${res.error ?? "unknown error"}`;
1956
+ return { content: [{ type: "text", text }] };
1957
+ },
1958
+ },
1959
+ unlink_experiment: {
1960
+ description: `${APEX} — Remove an Engineering Link from an experiment.`,
1961
+ schema: z.object({
1962
+ experimentId: z.string().describe("The experiment ID"),
1963
+ linkId: z
1964
+ .string()
1965
+ .describe('The link id, or "{system}#{externalId}" (e.g. github#482)'),
1966
+ }),
1967
+ handler: async ({ experimentId, linkId }) => {
1968
+ await apiDelete(`/api/experiments/${encodeURIComponent(experimentId)}/links/${encodeURIComponent(linkId)}`);
1969
+ return {
1970
+ content: [
1971
+ { type: "text", text: `Unlinked ${linkId} from experiment ${experimentId}.` },
1972
+ ],
1973
+ };
1974
+ },
1975
+ },
1976
+ // ─── Apex Developer CLI (Wave 6.5) — IDE debugging helpers ───────────,
1977
+ gate_experiment_on_merge: {
1978
+ description: `${APEX} — Arm a merge gate: bind an experiment to a linked PR so merging it auto-starts the test. Requires a frozen design spec (primary metric, minimum sample size, max duration, stopping rule) — this is the rigor gate that stops PR latency from changing the rules of the test. Link the PR first with link_experiment.`,
1979
+ schema: z.object({
1980
+ experimentId: z.string().describe("The experiment ID"),
1981
+ engineeringLinkId: z.string().describe("The link id of the PR (from list_engineering_links)"),
1982
+ rampOnMerge: z.number().min(0).max(100).optional().describe("Variant traffic split applied on merge (default 50)"),
1983
+ primaryMetric: z.string().describe("Primary metric key (must be one of the experiment's metrics)"),
1984
+ minSampleSize: z.number().int().positive().describe("Minimum per-variant sample size (must exceed the default 10)"),
1985
+ maxDurationDays: z.number().int().positive().describe("Max run duration in days (auto-stop)"),
1986
+ stoppingRule: z.string().describe('Stopping rule, e.g. "bayesian-95" or "fixed-horizon"'),
1987
+ }),
1988
+ handler: async (args) => {
1989
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(args.experimentId)}/gate`, {
1990
+ engineeringLinkId: args.engineeringLinkId,
1991
+ rampOnMerge: args.rampOnMerge ?? 50,
1992
+ designSpec: {
1993
+ primaryMetric: args.primaryMetric,
1994
+ minSampleSize: args.minSampleSize,
1995
+ maxDurationDays: args.maxDurationDays,
1996
+ stoppingRule: args.stoppingRule,
1997
+ },
1998
+ });
1999
+ const text = res.data
2000
+ ? `Merge gate armed for ${args.experimentId}. Status: ${res.data.status} (waiting on the PR to merge).`
2001
+ : `Could not arm gate: ${res.error ?? "unknown error"}${res.code ? ` (${res.code})` : ""}`;
2002
+ return { content: [{ type: "text", text }] };
2003
+ },
2004
+ },
2005
+ open_graduation_pr: {
2006
+ description: `${APEX} — Open an Apex-authored Graduation PR that inlines the winning variant in the merchant's repo. Requires the repo to be opted in for Apex writes (Automation Controls). The experiment must be promoted with a winner. No LLM — a deterministic, reviewable manifest + branded PR.`,
2007
+ schema: z.object({
2008
+ experimentId: z.string().describe("The experiment ID (must be promoted with a winner)"),
2009
+ }),
2010
+ handler: async ({ experimentId }) => {
2011
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/promote`, { openGraduationPr: true });
2012
+ const url = res.data?.graduationPrUrl;
2013
+ const text = url
2014
+ ? `Graduation PR opened: ${url}`
2015
+ : `Could not open graduation PR: ${res.error ?? "unknown error"}`;
2016
+ return { content: [{ type: "text", text }] };
2017
+ },
2018
+ },
2019
+ open_revert_pr: {
2020
+ description: `${APEX} — Open a native-format Revert PR removing a losing experiment's variant. Requires the repo to be opted in for Apex writes.`,
2021
+ schema: z.object({
2022
+ experimentId: z.string().describe("The experiment ID"),
2023
+ }),
2024
+ handler: async ({ experimentId }) => {
2025
+ const res = await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/open-revert-pr`, {});
2026
+ const url = res.data?.prUrl;
2027
+ const text = url
2028
+ ? `Revert PR opened: ${url}`
2029
+ : `Could not open revert PR: ${res.error ?? "unknown error"}`;
2030
+ return { content: [{ type: "text", text }] };
2031
+ },
2032
+ },
2033
+ get_automation_controls: {
2034
+ description: `${APEX} — Read the workspace's Graduation Pipeline Automation Controls (on-conclusion behavior, per-repo write allowlist, GitHub visibility, commit/branch conventions).`,
2035
+ schema: z.object({}),
2036
+ handler: async () => {
2037
+ const res = await apiGet(`/api/graduation-pipeline/automation`);
2038
+ return {
2039
+ content: [{ type: "text", text: JSON.stringify(res.data ?? res, null, 2) }],
2040
+ };
2041
+ },
2042
+ },
2043
+ update_automation_controls: {
2044
+ description: `${APEX} — Update the workspace's Graduation Pipeline Automation Controls. Only the fields you pass change. To let Apex open PRs to a repo, add it via writeRepos (or use the dedicated repos endpoint).`,
2045
+ schema: z.object({
2046
+ onWin: z.enum(["do_nothing", "notify", "auto"]).optional(),
2047
+ onLose: z.enum(["do_nothing", "notify", "auto"]).optional(),
2048
+ writeRepos: z.array(z.string()).optional(),
2049
+ githubVisibility: z.enum(["full", "minimal", "off"]).optional(),
2050
+ commitConvention: z.enum(["conventional", "none", "custom"]).optional(),
2051
+ branchConvention: z.enum(["apex", "feature", "feat", "chore", "custom"]).optional(),
2052
+ workingHours: z.enum(["anytime", "off_hours", "business_hours"]).optional(),
2053
+ }),
2054
+ handler: async (args) => {
2055
+ const res = await apiPatch(`/api/graduation-pipeline/automation`, args);
2056
+ return {
2057
+ content: [{ type: "text", text: `Automation Controls updated.\n${JSON.stringify(res.data ?? res, null, 2)}` }],
2058
+ };
2059
+ },
2060
+ },
2061
+ list_engineering_links: {
2062
+ description: `${APEX} — List the PRs and issues linked to an experiment, with their current state.`,
2063
+ schema: z.object({
2064
+ experimentId: z.string().describe("The experiment ID"),
2065
+ }),
2066
+ handler: async ({ experimentId }) => {
2067
+ const res = await apiGet(`/api/experiments/${encodeURIComponent(experimentId)}/links`);
2068
+ const links = res.data ?? [];
2069
+ if (links.length === 0) {
2070
+ return {
2071
+ content: [{ type: "text", text: `No engineering links for experiment ${experimentId}.` }],
2072
+ };
2073
+ }
2074
+ // Surface the link id — it is the required `engineering_link_id` input to
2075
+ // gate_experiment_on_merge. Without it the merge gate can't be armed
2076
+ // through MCP alone (staging dogfood gap 2026-07-16).
2077
+ const lines = links.map((l) => `- ${l.system} ${l.kind} ${l.repo ?? ""}#${l.externalId}${l.state ? ` (${l.state})` : ""}${l.title ? ` — ${l.title}` : ""}\n ${l.url}\n link id: ${l.id}`);
2078
+ return {
2079
+ content: [
2080
+ { type: "text", text: `# Engineering links — ${experimentId}\n\n${lines.join("\n")}` },
2081
+ ],
2082
+ };
2083
+ },
2084
+ },
2085
+ // ─── Conversions & Targets (Goals & Conversions Convergence — PR8) ──────────
2086
+ // A Conversion is the merchant-facing definition of a valued action (the
2087
+ // owned-event matcher managed on /dashboard/conversions). A Target is an
2088
+ // aggregate KPI number to hit by a date. "Goal" is retired as a noun.,
2089
+ };
2090
+ //# sourceMappingURL=experiments.js.map