@apex-inc/mcp-server 0.34.0 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/setup-catchup-tools.d.ts +98 -0
- package/dist/setup-catchup-tools.d.ts.map +1 -1
- package/dist/setup-catchup-tools.js +69 -0
- package/dist/setup-catchup-tools.js.map +1 -1
- package/dist/smoke-tools.d.ts.map +1 -1
- package/dist/smoke-tools.js +1 -0
- package/dist/smoke-tools.js.map +1 -1
- package/dist/tool-groups.d.ts.map +1 -1
- package/dist/tool-groups.js +7 -0
- package/dist/tool-groups.js.map +1 -1
- package/dist/tools/experiments.d.ts +224 -40
- package/dist/tools/experiments.d.ts.map +1 -1
- package/dist/tools/experiments.js +480 -7
- package/dist/tools/experiments.js.map +1 -1
- package/dist/tools/letters.d.ts +2 -2
- package/dist/tools/partners.d.ts +33 -28
- package/dist/tools/partners.d.ts.map +1 -1
- package/dist/tools/partners.js +16 -1
- package/dist/tools/partners.js.map +1 -1
- package/dist/tools.d.ts +355 -70
- package/dist/tools.d.ts.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +35 -0
- package/skills/apex-growth-intelligence/SKILL.md +22 -1
- package/skills/apex-growth-tracking/SKILL.md +10 -1
- package/skills/apex-integration-cookbook/SKILL.md +23 -0
- package/skills/apex-journeys/SKILL.md +19 -0
|
@@ -7,6 +7,156 @@ import { activationLiveLine, shouldRefuseActivationWiring } from "../activation-
|
|
|
7
7
|
import { fetchLiveTestHits, formatLiveTestStop, isFileStop, isObjectStop, } from "../live-test-guard.js";
|
|
8
8
|
import { hasConflictAdvisory, renderConflictAdvisory, } from "../conflict-advisory.js";
|
|
9
9
|
import { APEX, controlPctOf, daysRunningOf, findVariant, formatArmLine, titleCaseEvent, assertTargetUrlForActiveWorkspace, errMsg, resolveCreateSurface, } from "../tool-shared.js";
|
|
10
|
+
const NETWORK_WORD = {
|
|
11
|
+
meta: "Meta",
|
|
12
|
+
google: "Google",
|
|
13
|
+
tiktok: "TikTok",
|
|
14
|
+
linkedin: "LinkedIn",
|
|
15
|
+
};
|
|
16
|
+
function textError(text) {
|
|
17
|
+
return { content: [{ type: "text", text: `${APEX} ${text}` }], isError: true };
|
|
18
|
+
}
|
|
19
|
+
async function createAdHoldout(args) {
|
|
20
|
+
if (args.surface && args.surface !== "paid_channel") {
|
|
21
|
+
return textError(`paid_channel only applies to surface "paid_channel" (got "${args.surface}").`);
|
|
22
|
+
}
|
|
23
|
+
const pc = args.paid_channel;
|
|
24
|
+
if (!pc) {
|
|
25
|
+
return textError(`An Ad holdout needs a paid_channel block: { network, ad_set_ids, holdout_percent, attach_mode? }. Use list_ad_measurement or the dashboard's Ad holdout page to find the ad set ids.`);
|
|
26
|
+
}
|
|
27
|
+
const networkWord = NETWORK_WORD[pc.network] ?? pc.network;
|
|
28
|
+
const attachMode = pc.attach_mode ?? (pc.network === "meta" ? "apex" : "manual");
|
|
29
|
+
if (attachMode === "apex" && pc.network !== "meta") {
|
|
30
|
+
return textError(`Apex can put the list on the ad sets itself on Meta only. For ${networkWord} pass attach_mode "manual".`);
|
|
31
|
+
}
|
|
32
|
+
const adSetIds = [...new Set(pc.ad_set_ids.map((s) => s.trim()).filter(Boolean))];
|
|
33
|
+
if (adSetIds.length === 0)
|
|
34
|
+
return textError("ad_set_ids must name at least one ad set.");
|
|
35
|
+
if (adSetIds.length > 20)
|
|
36
|
+
return textError("An Ad holdout covers at most 20 ad sets.");
|
|
37
|
+
// What the workspace has already set up for this network: the connected
|
|
38
|
+
// ad account, whether the shop agreed to hashed emails going to the
|
|
39
|
+
// network, and (Meta) whether Apex proved it can hold people back there.
|
|
40
|
+
let prereqs = null;
|
|
41
|
+
try {
|
|
42
|
+
const r = await apiGet(`/api/experiments/ad-holdout/prereqs?network=${encodeURIComponent(pc.network)}`);
|
|
43
|
+
prereqs = r.data;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return textError(`Could not read the Ad holdout prerequisites: ${errMsg(err)}`);
|
|
47
|
+
}
|
|
48
|
+
if (!prereqs)
|
|
49
|
+
return textError("Could not read the Ad holdout prerequisites for the active workspace.");
|
|
50
|
+
if (!prereqs.enabled) {
|
|
51
|
+
return textError("Ad holdouts are not switched on for this workspace yet. Turn them on in the dashboard (Settings → Platforms) first.");
|
|
52
|
+
}
|
|
53
|
+
const net = prereqs.networks.find((n) => n.network === pc.network);
|
|
54
|
+
const blockers = [];
|
|
55
|
+
if (!net?.connected) {
|
|
56
|
+
blockers.push(`${networkWord} Ads is not connected. Run the "Pick your ad platforms" setup step (or start_integration_install) first.`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
if (!net.audienceAcked) {
|
|
60
|
+
blockers.push(`The shop has not yet agreed to hashed emails going to ${networkWord}. Finish the "Let Apex hold people back from your ads" setup step.`);
|
|
61
|
+
}
|
|
62
|
+
if (net.attachAvailable && !net.accessProven) {
|
|
63
|
+
blockers.push(`Apex has not yet proved its ${networkWord} token can put a do-not-show list on this account. The same setup step runs the two checks.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const adAccountId = pc.ad_account_id?.trim() || net?.adAccountId;
|
|
67
|
+
if (!adAccountId) {
|
|
68
|
+
blockers.push(`No ${networkWord} ad account is known for this workspace. Pass ad_account_id or connect the account.`);
|
|
69
|
+
}
|
|
70
|
+
const known = new Set(prereqs.adSets.map((a) => a.id));
|
|
71
|
+
const unknown = known.size > 0 ? adSetIds.filter((id) => !known.has(id)) : [];
|
|
72
|
+
if (unknown.length > 0) {
|
|
73
|
+
blockers.push(`These ad set ids are not on the connected ${networkWord} account: ${unknown.join(", ")}. Known ad sets: ${prereqs.adSets.slice(0, 12).map((a) => `${a.id} (${a.name})`).join(", ")}${prereqs.adSets.length > 12 ? ", …" : ""}.`);
|
|
74
|
+
}
|
|
75
|
+
if (blockers.length > 0) {
|
|
76
|
+
return textError(`This Ad holdout cannot start yet:\n- ${blockers.join("\n- ")}`);
|
|
77
|
+
}
|
|
78
|
+
const picked = prereqs.adSets.filter((a) => adSetIds.includes(a.id));
|
|
79
|
+
const campaignNames = [...new Set(picked.map((a) => a.campaignName).filter((c) => Boolean(c)))];
|
|
80
|
+
const weeks = Math.max(1, Math.round((args.runtimeDays ?? 28) / 7));
|
|
81
|
+
const heldBack = Math.round(prereqs.population.knownPeople * pc.holdout_percent);
|
|
82
|
+
const hypothesis = args.hypothesisStatement?.trim() ||
|
|
83
|
+
`The ${networkWord} ads on ${adSetIds.length === 1 ? "this ad set" : `these ${adSetIds.length} ad sets`} cause installs that would not have happened without them.`;
|
|
84
|
+
const summary = {
|
|
85
|
+
_apex: true,
|
|
86
|
+
_type: "ad_holdout_preview",
|
|
87
|
+
name: args.name,
|
|
88
|
+
surface: "paid_channel",
|
|
89
|
+
network: networkWord,
|
|
90
|
+
ad_account_id: adAccountId,
|
|
91
|
+
ad_sets: picked.length > 0 ? picked.map((a) => ({ id: a.id, name: a.name, campaign: a.campaignName ?? null })) : adSetIds,
|
|
92
|
+
holdout_percent: pc.holdout_percent,
|
|
93
|
+
attach_mode: attachMode,
|
|
94
|
+
washout_days: pc.washout_days ?? 3,
|
|
95
|
+
runtime_days: weeks * 7,
|
|
96
|
+
known_people: prereqs.population.knownPeople,
|
|
97
|
+
people_held_back_now: heldBack,
|
|
98
|
+
what_is_measured: "App installs in both lanes (saw the ads vs held back). Purchases can be a secondary metric.",
|
|
99
|
+
hypothesis,
|
|
100
|
+
honesty: [
|
|
101
|
+
`Nothing counts until Apex confirms the do-not-show list is on every ad set${attachMode === "manual" ? ` — with manual attach the shop adds the audience as an exclusion in ${networkWord} Ads` : ""}.`,
|
|
102
|
+
"Results freeze if the list comes off mid-run; start a new holdout to measure again.",
|
|
103
|
+
"Installs are counted for known people only (someone with an email or phone Apex can hash).",
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
if (args.preview !== false) {
|
|
107
|
+
return {
|
|
108
|
+
content: [{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: JSON.stringify({
|
|
111
|
+
...summary,
|
|
112
|
+
_instructions: "Present this as a summary and ask the user to confirm (1), adjust (2), or cancel (3). Do NOT create the holdout until confirmed. Then call again with preview=false, then activate_experiment to start drawing the list, and verify_ad_exclusion once the list has landed.",
|
|
113
|
+
}, null, 2),
|
|
114
|
+
}],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const exp = await apiPost("/api/experiments", {
|
|
118
|
+
surface: "paid_channel",
|
|
119
|
+
name: args.name,
|
|
120
|
+
hypothesis,
|
|
121
|
+
createdFrom: "cursor",
|
|
122
|
+
status: "draft",
|
|
123
|
+
paidChannel: {
|
|
124
|
+
network: pc.network,
|
|
125
|
+
adAccountId,
|
|
126
|
+
adSetIds,
|
|
127
|
+
holdoutPercent: pc.holdout_percent,
|
|
128
|
+
attachMode,
|
|
129
|
+
washoutDays: pc.washout_days ?? 3,
|
|
130
|
+
...(pc.population_segment_id ? { populationSegmentId: pc.population_segment_id } : {}),
|
|
131
|
+
...(campaignNames.length > 0 ? { campaignNames } : {}),
|
|
132
|
+
},
|
|
133
|
+
decisionWindow: { unit: "days", value: weeks * 7, startFrom: "assignment" },
|
|
134
|
+
...(args.beliefId ? { beliefId: args.beliefId } : {}),
|
|
135
|
+
...(args.hypothesisId ? { hypothesisId: args.hypothesisId } : {}),
|
|
136
|
+
...(args.predictionId ? { predictionId: args.predictionId } : {}),
|
|
137
|
+
prediction: {
|
|
138
|
+
direction: "increase",
|
|
139
|
+
magnitude: args.predictionMagnitude?.trim() ?? "",
|
|
140
|
+
provenance: "agent",
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
content: [{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: JSON.stringify({
|
|
147
|
+
...summary,
|
|
148
|
+
_type: "ad_holdout_created",
|
|
149
|
+
experimentId: exp.id,
|
|
150
|
+
status: exp.status,
|
|
151
|
+
_next: [
|
|
152
|
+
`activate_experiment({ experimentId: "${exp.id}" }) — Apex draws the held-back list and ${attachMode === "apex" ? `puts it on the ${networkWord} ad sets` : `makes the audience for the shop to add as an exclusion in ${networkWord} Ads`}.`,
|
|
153
|
+
`verify_ad_exclusion({ experiment_id: "${exp.id}" }) — confirm the list is on every ad set. Counting starts after the washout.`,
|
|
154
|
+
`get_results({ experimentId: "${exp.id}" }) — the two lanes, once counting.`,
|
|
155
|
+
],
|
|
156
|
+
}, null, 2),
|
|
157
|
+
}],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
10
160
|
export const experimentTools = {
|
|
11
161
|
plan_experiment: {
|
|
12
162
|
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.`,
|
|
@@ -154,9 +304,9 @@ export const experimentTools = {
|
|
|
154
304
|
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
305
|
schema: z.object({
|
|
156
306
|
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"),
|
|
307
|
+
targetUrl: z.string().optional().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. Required for web and mobile; not used for surface 'paid_channel'."),
|
|
308
|
+
controlContent: z.string().optional().describe("The current content that the control shows. Required for web and mobile; not used for surface 'paid_channel'."),
|
|
309
|
+
variantContent: z.string().optional().describe("The new content for the variant. Required for web and mobile; not used for surface 'paid_channel'."),
|
|
160
310
|
targetComponent: z.string().optional().describe("Component or file path where the change should be made"),
|
|
161
311
|
targetAnchor: z.string().optional().describe("Section anchor/ID on the page (e.g. 'pricing', 'hero')"),
|
|
162
312
|
trafficSplit: z.number().min(1).max(99).optional().describe("Percentage of traffic to control (default 50)"),
|
|
@@ -173,7 +323,19 @@ export const experimentTools = {
|
|
|
173
323
|
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
324
|
guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
|
|
175
325
|
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)."),
|
|
326
|
+
surface: z.enum(["web", "mobile", "paid_channel"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' for a website, 'mobile' for a native / Capacitor / React-Native app, 'paid_channel' for an Ad holdout (Apex holds a share of known people back from the shop's ads on one network and measures the installs the ads actually caused — pass paid_channel too). OPTIONAL for web/mobile — 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 vs Ads)."),
|
|
327
|
+
paid_channel: z
|
|
328
|
+
.object({
|
|
329
|
+
network: z.enum(["meta", "google", "tiktok", "linkedin"]).describe("Ad network the ad sets run on. Apex attaches the do-not-show list itself on Meta only; other networks are manual attach."),
|
|
330
|
+
ad_account_id: z.string().optional().describe("The connected ad account the ad sets belong to. Omit to use the account the workspace connected for this network."),
|
|
331
|
+
ad_set_ids: z.array(z.string()).min(1).describe("Ad set ids to hold people back from (max 20). Frozen at create; changing them is a new holdout."),
|
|
332
|
+
holdout_percent: z.number().min(0.05).max(0.5).describe("Share of the known population held back, 0.05–0.5 (0.2 = one in five)."),
|
|
333
|
+
attach_mode: z.enum(["apex", "manual"]).optional().describe("'apex' = Apex puts the list on the ad sets and checks daily (Meta only). 'manual' = the shop adds the audience as an exclusion by hand; Apex checks daily and counts nothing until it is on. Defaults to 'apex' on Meta, 'manual' elsewhere."),
|
|
334
|
+
washout_days: z.number().int().min(0).max(30).optional().describe("Days after the list lands before installs count, so pre-list ad exposure washes out. Default 3."),
|
|
335
|
+
population_segment_id: z.string().optional().describe("Optional Segment id narrowing who can be drawn."),
|
|
336
|
+
})
|
|
337
|
+
.optional()
|
|
338
|
+
.describe("Ad holdout settings. Required when surface is 'paid_channel'; rejected otherwise. The primary metric is always app installs, so primaryMetricEvent is not needed."),
|
|
177
339
|
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
340
|
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
341
|
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."),
|
|
@@ -181,6 +343,22 @@ export const experimentTools = {
|
|
|
181
343
|
preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
|
|
182
344
|
}),
|
|
183
345
|
handler: async (args) => {
|
|
346
|
+
// Mobile Measurement P3a — an Ad holdout is not a code change. No page,
|
|
347
|
+
// no control/variant copy, no recipe: Apex draws the held-back list,
|
|
348
|
+
// puts it on the ad sets, and counts installs. Branch before any of the
|
|
349
|
+
// web/mobile checks so a missing targetUrl is not an error here.
|
|
350
|
+
if (args.surface === "paid_channel" || args.paid_channel) {
|
|
351
|
+
return createAdHoldout(args);
|
|
352
|
+
}
|
|
353
|
+
if (!args.targetUrl || !args.controlContent || !args.variantContent) {
|
|
354
|
+
return {
|
|
355
|
+
content: [{
|
|
356
|
+
type: "text",
|
|
357
|
+
text: `${APEX} targetUrl, controlContent and variantContent are required for a web or mobile experiment. (For an Ad holdout pass surface "paid_channel" and a paid_channel block instead.)`,
|
|
358
|
+
}],
|
|
359
|
+
isError: true,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
184
362
|
const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
|
|
185
363
|
if (!targetCheck.ok) {
|
|
186
364
|
return {
|
|
@@ -477,7 +655,7 @@ export const experimentTools = {
|
|
|
477
655
|
});
|
|
478
656
|
let previewUrl = "";
|
|
479
657
|
try {
|
|
480
|
-
const vu = new URL(exp.targetUrl || args.targetUrl);
|
|
658
|
+
const vu = new URL(exp.targetUrl || args.targetUrl || "");
|
|
481
659
|
vu.searchParams.set("_apex_preview", "variant_a");
|
|
482
660
|
vu.searchParams.set("_apex_exp", exp.id);
|
|
483
661
|
if (args.targetAnchor)
|
|
@@ -1248,12 +1426,307 @@ export const experimentTools = {
|
|
|
1248
1426
|
};
|
|
1249
1427
|
},
|
|
1250
1428
|
},
|
|
1429
|
+
list_belief_suggestions: {
|
|
1430
|
+
description: `${APEX} — "Worth writing down": settled experiments whose verdict passes the belief gate (decisive, exclusion attached the whole window for Ad holdouts, no sample-ratio mismatch, no guardrail breach, both arms over the events floor) and have not been written down as a belief yet. Statistical only; no model in the path. Pass experimentId for one experiment and, when nothing is offered, 'blocker' says why in one word. Each row carries the sentence Apex would write and the evidence line; write one down with accept_belief_suggestion.`,
|
|
1431
|
+
schema: z.object({
|
|
1432
|
+
experimentId: z.string().optional().describe("One experiment. Omit for the workspace's rail (newest settled first)."),
|
|
1433
|
+
max: z.number().int().min(1).max(10).optional().describe("Cap for the rail (default 5)."),
|
|
1434
|
+
}),
|
|
1435
|
+
handler: async (args) => {
|
|
1436
|
+
const qs = new URLSearchParams();
|
|
1437
|
+
if (args.experimentId?.trim())
|
|
1438
|
+
qs.set("experiment_id", args.experimentId.trim());
|
|
1439
|
+
if (args.max !== undefined)
|
|
1440
|
+
qs.set("max", String(args.max));
|
|
1441
|
+
const q = qs.toString();
|
|
1442
|
+
try {
|
|
1443
|
+
const r = await apiGet(`/api/experiments/belief-suggestions${q ? `?${q}` : ""}`);
|
|
1444
|
+
const d = r.data;
|
|
1445
|
+
const blockerWords = {
|
|
1446
|
+
not_decisive: "The result is not decisive yet; Apex does not write down a belief from a test that has not settled.",
|
|
1447
|
+
exclusion_not_attached: "The held-back list was not on every ad set for the whole window, so the held-out arm saw ads and the comparison is not clean.",
|
|
1448
|
+
results_frozen: "The exclusion came off mid-run and the results were frozen; nothing after that counts.",
|
|
1449
|
+
srm: "The arms did not split the way they were supposed to (sample-ratio mismatch), so the comparison cannot be trusted.",
|
|
1450
|
+
guardrail_breach: "A guardrail was breached. A win that broke a guardrail is not a belief.",
|
|
1451
|
+
events_floor: "One arm has too few events to say anything yet.",
|
|
1452
|
+
already_written: "This verdict is already written down as a belief.",
|
|
1453
|
+
not_found: "No experiment with that id in this workspace.",
|
|
1454
|
+
};
|
|
1455
|
+
return {
|
|
1456
|
+
content: [{
|
|
1457
|
+
type: "text",
|
|
1458
|
+
text: JSON.stringify({
|
|
1459
|
+
_apex: true,
|
|
1460
|
+
suggestions: d.suggestions.map((s) => ({
|
|
1461
|
+
experiment_id: s.experimentId,
|
|
1462
|
+
experiment: s.experimentName,
|
|
1463
|
+
surface: s.surface,
|
|
1464
|
+
statement: s.statement,
|
|
1465
|
+
evidence: s.evidence,
|
|
1466
|
+
lift: s.lift,
|
|
1467
|
+
lift_ci: s.liftCI,
|
|
1468
|
+
window_days: s.windowDays,
|
|
1469
|
+
people: s.n,
|
|
1470
|
+
metric: s.metricLabel,
|
|
1471
|
+
})),
|
|
1472
|
+
blocker: d.blocker ?? null,
|
|
1473
|
+
existing_belief_id: d.existingBeliefId ?? null,
|
|
1474
|
+
in_plain_words: d.suggestions.length > 0
|
|
1475
|
+
? `${d.suggestions.length} verdict${d.suggestions.length === 1 ? "" : "s"} worth writing down. Show the shop the sentence; they can edit it before accept_belief_suggestion writes it as one study.`
|
|
1476
|
+
: d.blocker
|
|
1477
|
+
? blockerWords[d.blocker] ?? `Not offered: ${d.blocker}.`
|
|
1478
|
+
: "Nothing worth writing down right now. Suggestions appear when an experiment settles cleanly.",
|
|
1479
|
+
}, null, 2),
|
|
1480
|
+
}],
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
catch (err) {
|
|
1484
|
+
return textError(`Could not read the suggestions: ${errMsg(err)}`);
|
|
1485
|
+
}
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
accept_belief_suggestion: {
|
|
1489
|
+
description: `${APEX} — Write a settled experiment's verdict down as one belief. Creates the Belief with the experiment as its supporting evidence and generatedFromExperimentId set; its evidence state starts at 'one study' and moves to 'validated' when a second test agrees. 'statement' is the shop's edit of the sentence (the numbers and evidence line stay Apex's). The gate re-runs here, so an experiment list_belief_suggestions would not offer is refused with its blocker. Writing it twice returns the existing belief. Admin only.`,
|
|
1490
|
+
schema: z.object({
|
|
1491
|
+
experimentId: z.string().describe("The settled experiment to write down."),
|
|
1492
|
+
statement: z.string().min(8).max(600).optional().describe("The shop's wording of the belief. Omit to accept Apex's sentence as written."),
|
|
1493
|
+
}),
|
|
1494
|
+
handler: async (args) => {
|
|
1495
|
+
const id = args.experimentId.trim();
|
|
1496
|
+
if (!id)
|
|
1497
|
+
return textError("experimentId is required.");
|
|
1498
|
+
try {
|
|
1499
|
+
const r = await apiPost("/api/experiments/belief-suggestions/accept", { experimentId: id, ...(args.statement?.trim() ? { statement: args.statement.trim() } : {}) });
|
|
1500
|
+
const b = r.data;
|
|
1501
|
+
return {
|
|
1502
|
+
content: [{
|
|
1503
|
+
type: "text",
|
|
1504
|
+
text: JSON.stringify({
|
|
1505
|
+
_apex: true,
|
|
1506
|
+
belief_id: b.id,
|
|
1507
|
+
statement: b.statement,
|
|
1508
|
+
confidence: b.confidence,
|
|
1509
|
+
evidence_state: "one_study",
|
|
1510
|
+
generated_from_experiment_id: id,
|
|
1511
|
+
in_plain_words: "Written down. It is on the shop's Belief Map as one study with this experiment as the evidence. A second test that agrees moves it to validated.",
|
|
1512
|
+
}, null, 2),
|
|
1513
|
+
}],
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
const message = errMsg(err);
|
|
1518
|
+
if (/\(409\)/.test(message)) {
|
|
1519
|
+
return textError("This verdict is already written down. list_beliefs shows it; nothing was duplicated.");
|
|
1520
|
+
}
|
|
1521
|
+
if (/\(422\)/.test(message)) {
|
|
1522
|
+
return textError(`Apex will not write this one down: ${message}. Run list_belief_suggestions with the experimentId to see the blocker in plain words.`);
|
|
1523
|
+
}
|
|
1524
|
+
return textError(`Could not write the belief: ${message}`);
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
verify_ad_exclusion: {
|
|
1529
|
+
description: `${APEX} — Ad holdout only (surface paid_channel). Ask Apex to check, right now, whether the do-not-show list is on every ad set the holdout covers, and record the answer. Nothing is counted until this reads "attached"; the daily check runs on its own, so call this after the list lands or after the shop attached it by hand. Rate-limited: a recent check returns the last reading instead of re-reading.`,
|
|
1530
|
+
schema: z.object({
|
|
1531
|
+
experimentId: z.string().describe("The Ad holdout experiment id."),
|
|
1532
|
+
}),
|
|
1533
|
+
handler: async (args) => {
|
|
1534
|
+
const id = args.experimentId.trim();
|
|
1535
|
+
if (!id)
|
|
1536
|
+
return textError("experimentId is required.");
|
|
1537
|
+
try {
|
|
1538
|
+
const r = await apiGet(`/api/experiments/${encodeURIComponent(id)}/ad-exclusion/verify`);
|
|
1539
|
+
const d = r.data;
|
|
1540
|
+
const words = {
|
|
1541
|
+
attached: "The list is on every ad set. Counting starts after the washout.",
|
|
1542
|
+
pending: "The list is not on the ad sets yet.",
|
|
1543
|
+
syncing: "The network is still matching the list.",
|
|
1544
|
+
detached: "The list came off at least one ad set. Results are frozen where they stood.",
|
|
1545
|
+
contaminated: "The held-back people saw these ads. Results are frozen; start a new holdout to measure again.",
|
|
1546
|
+
too_small: "The network matched too few people to honour the list.",
|
|
1547
|
+
token_expired: "The network no longer accepts Apex's connection. Reconnect the ad account.",
|
|
1548
|
+
unverifiable: "The network does not let Apex read whether the list is on.",
|
|
1549
|
+
failed_verify: "Apex could not confirm the list is on the ad sets.",
|
|
1550
|
+
};
|
|
1551
|
+
return {
|
|
1552
|
+
content: [{
|
|
1553
|
+
type: "text",
|
|
1554
|
+
text: JSON.stringify({
|
|
1555
|
+
_apex: true,
|
|
1556
|
+
experimentId: d.experimentId,
|
|
1557
|
+
exclusion_status: d.exclusionStatus,
|
|
1558
|
+
in_plain_words: words[d.exclusionStatus] ?? d.exclusionStatus,
|
|
1559
|
+
checked_just_now: !d.skipped,
|
|
1560
|
+
last_checked_at: d.lastVerifiedAt ?? null,
|
|
1561
|
+
confirmed_since: d.exclusionVerifiedAt ?? null,
|
|
1562
|
+
list: typeof d.uploadedCount === "number"
|
|
1563
|
+
? { sent: d.uploadedCount, matched: d.matchedCount ?? null }
|
|
1564
|
+
: null,
|
|
1565
|
+
ad_sets: d.adSets ?? null,
|
|
1566
|
+
results_frozen_at: d.resultsFrozenAt ?? null,
|
|
1567
|
+
last_error: d.lastVerifyError ?? null,
|
|
1568
|
+
}, null, 2),
|
|
1569
|
+
}],
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
catch (err) {
|
|
1573
|
+
const message = errMsg(err);
|
|
1574
|
+
if (/\(429\)/.test(message)) {
|
|
1575
|
+
return {
|
|
1576
|
+
content: [{
|
|
1577
|
+
type: "text",
|
|
1578
|
+
text: `${APEX} Apex checked this exclusion recently and will not re-read yet. Use get_results or list_experiments for the last reading, or try again in a few minutes.`,
|
|
1579
|
+
}],
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
return textError(`Could not verify the exclusion: ${message}`);
|
|
1583
|
+
}
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
attach_ad_exclusion: {
|
|
1587
|
+
description: `${APEX} — Ad holdout only (surface paid_channel, Meta only). Apex puts the held-back list on the holdout's ad sets itself: exactly one exclusion added per ad set, every other targeting key (locations, age, placements, ...) left as read, then read back to confirm. Two steps, always in this order: (1) call with preview=true (default) and SHOW the shop the before/after per ad set — which ad sets get the exclusion, which already have it, which are on another account and will not be touched; (2) once the shop confirms, call with preview=false. The first real attach in a workspace needs acknowledge=true, which records the shop's standing allowance for Apex to edit ad-set targeting (say so before passing it; never pass it without the shop having seen the preview). Refuses with needs_targeting_ack when it is missing, list_not_on_network while the list is still syncing, and not for Google/TikTok/LinkedIn (there the shop adds the list in the network's tools and verify_ad_exclusion checks). Ending, pausing or archiving the holdout takes the list off again on its own. Not billed.`,
|
|
1588
|
+
schema: z.object({
|
|
1589
|
+
experimentId: z.string().describe("The Ad holdout experiment id."),
|
|
1590
|
+
preview: z.boolean().optional().describe("true (default): read-only — return the before/after per ad set and whether the allowance is on file. false: make the edit."),
|
|
1591
|
+
acknowledge: z.boolean().optional().describe("Pass true on the first real attach in this workspace, after the shop has seen the preview and agreed that Apex may edit ad-set targeting. Recorded once per network."),
|
|
1592
|
+
}),
|
|
1593
|
+
handler: async (args) => {
|
|
1594
|
+
const id = args.experimentId.trim();
|
|
1595
|
+
if (!id)
|
|
1596
|
+
return textError("experimentId is required.");
|
|
1597
|
+
const path = `/api/experiments/${encodeURIComponent(id)}/ad-exclusion/attach`;
|
|
1598
|
+
const ids = (t) => (t?.excluded_custom_audiences ?? []).map((a) => String(a.id));
|
|
1599
|
+
try {
|
|
1600
|
+
if (args.preview !== false) {
|
|
1601
|
+
const r = await apiGet(path);
|
|
1602
|
+
const d = r.data;
|
|
1603
|
+
const rows = d.adSets.map((a) => ({
|
|
1604
|
+
ad_set_id: a.adSetId,
|
|
1605
|
+
name: a.name ?? null,
|
|
1606
|
+
apex_will: !a.found ? "leave it (could not read it)" : !a.sameAccount ? "leave it (another ad account)" : a.alreadyPresent ? "leave it (list already on)" : "add the held-back list as one exclusion",
|
|
1607
|
+
excluded_audiences_before: ids(a.before),
|
|
1608
|
+
excluded_audiences_after: a.after ? ids(a.after) : ids(a.before),
|
|
1609
|
+
everything_else: a.found ? "unchanged" : null,
|
|
1610
|
+
note: a.message ?? null,
|
|
1611
|
+
}));
|
|
1612
|
+
const reasonWords = {
|
|
1613
|
+
no_connection: "Meta Ads is not connected with outbound access.",
|
|
1614
|
+
list_not_on_network: "The held-back list has not reached Meta yet. Activate the holdout and wait for the sync; then attach.",
|
|
1615
|
+
adsets_not_ready: "At least one ad set could not be read or is on another ad account. Apex will not touch those.",
|
|
1616
|
+
};
|
|
1617
|
+
return {
|
|
1618
|
+
content: [{
|
|
1619
|
+
type: "text",
|
|
1620
|
+
text: JSON.stringify({
|
|
1621
|
+
_apex: true,
|
|
1622
|
+
experimentId: id,
|
|
1623
|
+
preview: true,
|
|
1624
|
+
ready: d.ready,
|
|
1625
|
+
why_not_ready: d.reason ? reasonWords[d.reason] ?? d.reason : null,
|
|
1626
|
+
allowance_on_file: d.ack.present,
|
|
1627
|
+
allowance_since: d.ack.at ?? null,
|
|
1628
|
+
held_back_list_id: d.audienceId,
|
|
1629
|
+
ad_sets: rows,
|
|
1630
|
+
_next: d.ready
|
|
1631
|
+
? d.ack.present
|
|
1632
|
+
? `Show the shop this table. On its word, call attach_ad_exclusion({ experiment_id: "${id}", preview: false }).`
|
|
1633
|
+
: `Show the shop this table and say: confirming allows Apex to add the held-back list as one exclusion on holdout ad sets and remove it when a holdout ends; nothing else on the ad sets is changed. On its word, call attach_ad_exclusion({ experiment_id: "${id}", preview: false, acknowledge: true }).`
|
|
1634
|
+
: "Fix the reason above, then preview again.",
|
|
1635
|
+
}, null, 2),
|
|
1636
|
+
}],
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
const r = await apiPost(path, args.acknowledge ? { acknowledge: true } : {});
|
|
1640
|
+
const d = r.data;
|
|
1641
|
+
const words = {
|
|
1642
|
+
attached: "The list is on every ad set and Meta confirmed it. Counting starts after the washout.",
|
|
1643
|
+
syncing: "Apex put the list on every ad set. It is checking Meta has taken it; verify_ad_exclusion reads the answer.",
|
|
1644
|
+
failed_verify: "The list is on some ad sets, not all. See the changes below and last_error.",
|
|
1645
|
+
token_expired: "Meta no longer accepts Apex's connection. Reconnect Meta Ads and attach again.",
|
|
1646
|
+
};
|
|
1647
|
+
return {
|
|
1648
|
+
content: [{
|
|
1649
|
+
type: "text",
|
|
1650
|
+
text: JSON.stringify({
|
|
1651
|
+
_apex: true,
|
|
1652
|
+
experimentId: id,
|
|
1653
|
+
exclusion_status: d.exclusionStatus,
|
|
1654
|
+
in_plain_words: words[d.exclusionStatus] ?? d.exclusionStatus,
|
|
1655
|
+
every_ad_set_took_it: d.allSettled,
|
|
1656
|
+
apex_attached_at: d.apexAttachedAt ?? null,
|
|
1657
|
+
changes: d.changes.map((c) => ({
|
|
1658
|
+
ad_set_id: c.adSetId,
|
|
1659
|
+
name: c.name ?? null,
|
|
1660
|
+
outcome: c.outcome,
|
|
1661
|
+
excluded_audiences_before: c.before ?? null,
|
|
1662
|
+
excluded_audiences_after: c.after ?? null,
|
|
1663
|
+
note: c.message ?? null,
|
|
1664
|
+
})),
|
|
1665
|
+
last_error: d.lastVerifyError ?? null,
|
|
1666
|
+
_next: d.allSettled
|
|
1667
|
+
? `verify_ad_exclusion({ experiment_id: "${id}" }) once Meta has had a minute, or get_results after the washout.`
|
|
1668
|
+
: "Read each change's note; a rate-limited or locked ad set can simply be attached again.",
|
|
1669
|
+
}, null, 2),
|
|
1670
|
+
}],
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
catch (err) {
|
|
1674
|
+
const message = errMsg(err);
|
|
1675
|
+
if (/needs_targeting_ack|\(409\)/.test(message)) {
|
|
1676
|
+
return textError(`Apex will not edit ad sets until the shop has allowed it. Call attach_ad_exclusion with preview=true, show the before/after, and on the shop's word call again with preview=false and acknowledge=true.`);
|
|
1677
|
+
}
|
|
1678
|
+
if (/network_not_supported/.test(message)) {
|
|
1679
|
+
return textError("Apex attaches the list itself on Meta only. On other networks the shop adds the list in the network's own tools; verify_ad_exclusion checks it is on.");
|
|
1680
|
+
}
|
|
1681
|
+
return textError(`Could not attach the held-back list: ${message}`);
|
|
1682
|
+
}
|
|
1683
|
+
},
|
|
1684
|
+
},
|
|
1685
|
+
detach_ad_exclusion: {
|
|
1686
|
+
description: `${APEX} — Ad holdout only (surface paid_channel, Meta only). Take Apex's exclusion back off the holdout's ad sets and delete the held-back list from Meta. Removes only what Apex added: a list the shop attached by hand is refused (not_apex_attached) and the shop removes it in Ads Manager. Ending, pausing or archiving the holdout does this on its own; call this only to undo an attach early. Admin only. Confirm with the shop first: the held-back people start seeing the ads again and the holdout stops measuring.`,
|
|
1687
|
+
schema: z.object({
|
|
1688
|
+
experimentId: z.string().describe("The Ad holdout experiment id."),
|
|
1689
|
+
}),
|
|
1690
|
+
handler: async (args) => {
|
|
1691
|
+
const id = args.experimentId.trim();
|
|
1692
|
+
if (!id)
|
|
1693
|
+
return textError("experimentId is required.");
|
|
1694
|
+
try {
|
|
1695
|
+
const r = await apiPost(`/api/experiments/${encodeURIComponent(id)}/ad-exclusion/detach`, {});
|
|
1696
|
+
const d = r.data;
|
|
1697
|
+
return {
|
|
1698
|
+
content: [{
|
|
1699
|
+
type: "text",
|
|
1700
|
+
text: JSON.stringify({
|
|
1701
|
+
_apex: true,
|
|
1702
|
+
experimentId: id,
|
|
1703
|
+
every_ad_set_restored: d.ok,
|
|
1704
|
+
held_back_list_deleted: d.listDeleted,
|
|
1705
|
+
exclusion_status: d.exclusionStatus ?? null,
|
|
1706
|
+
in_plain_words: d.ok
|
|
1707
|
+
? "Apex took its exclusion off every ad set and deleted the held-back list. The ad sets are as they were before the holdout."
|
|
1708
|
+
: "Apex could not take the list off every ad set. See the changes; the list stays until they are clean.",
|
|
1709
|
+
changes: d.changes.map((c) => ({ ad_set_id: c.adSetId, name: c.name ?? null, outcome: c.outcome, note: c.message ?? null })),
|
|
1710
|
+
last_error: d.lastVerifyError ?? null,
|
|
1711
|
+
}, null, 2),
|
|
1712
|
+
}],
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
catch (err) {
|
|
1716
|
+
const message = errMsg(err);
|
|
1717
|
+
if (/not_apex_attached|\(409\)/.test(message)) {
|
|
1718
|
+
return textError("Apex did not put this list on the ad sets, so it will not take it off. The shop removes the exclusion in Ads Manager.");
|
|
1719
|
+
}
|
|
1720
|
+
return textError(`Could not take the held-back list off: ${message}`);
|
|
1721
|
+
}
|
|
1722
|
+
},
|
|
1723
|
+
},
|
|
1251
1724
|
update_experiment: {
|
|
1252
1725
|
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
1726
|
schema: z.object({
|
|
1254
1727
|
experimentId: z.string().describe("The experiment id to edit."),
|
|
1255
1728
|
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)."),
|
|
1729
|
+
surface: z.enum(["web", "mobile", "paid_channel"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile). 'paid_channel' is accepted only when the draft already carries its Ad holdout block."),
|
|
1257
1730
|
hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
|
|
1258
1731
|
targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
|
|
1259
1732
|
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."),
|
|
@@ -1395,7 +1868,7 @@ export const experimentTools = {
|
|
|
1395
1868
|
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
1869
|
schema: z.object({
|
|
1397
1870
|
experimentId: z.string().optional().describe("Check an existing experiment by id (loads its location + metric)."),
|
|
1398
|
-
surface: z.enum(["web", "mobile"]).optional(),
|
|
1871
|
+
surface: z.enum(["web", "mobile", "paid_channel"]).optional(),
|
|
1399
1872
|
targetComponent: z.string().optional().describe("Component/file the experiment changes, e.g. 'ProductCard'."),
|
|
1400
1873
|
targetUrl: z.string().optional(),
|
|
1401
1874
|
targetAnchor: z.string().optional(),
|