@apex-inc/mcp-server 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/setup-catchup-tools.d.ts +84 -5
- package/dist/setup-catchup-tools.d.ts.map +1 -1
- package/dist/setup-catchup-tools.js +33 -3
- package/dist/setup-catchup-tools.js.map +1 -1
- package/dist/tool-groups.d.ts.map +1 -1
- package/dist/tool-groups.js +3 -0
- package/dist/tool-groups.js.map +1 -1
- package/dist/tools/ad-platforms.d.ts +3 -3
- package/dist/tools/ad-platforms.js +2 -2
- package/dist/tools/ad-platforms.js.map +1 -1
- package/dist/tools/apex-campaigns.d.ts +133 -0
- package/dist/tools/apex-campaigns.d.ts.map +1 -0
- package/dist/tools/apex-campaigns.js +156 -0
- package/dist/tools/apex-campaigns.js.map +1 -0
- package/dist/tools/experiments.d.ts +8 -5
- package/dist/tools/experiments.d.ts.map +1 -1
- package/dist/tools/experiments.js +64 -41
- package/dist/tools/experiments.js.map +1 -1
- package/dist/tools/letters.d.ts +22 -12
- package/dist/tools/letters.d.ts.map +1 -1
- package/dist/tools/letters.js +23 -44
- package/dist/tools/letters.js.map +1 -1
- package/dist/tools.d.ts +240 -25
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +2 -0
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +6 -2
- package/skills/apex-growth-intelligence/SKILL.md +17 -0
- package/skills/apex-growth-tracking/SKILL.md +1 -0
- package/skills/apex-integration-cookbook/SKILL.md +10 -0
- package/skills/apex-journeys/SKILL.md +17 -0
|
@@ -17,6 +17,27 @@ const NETWORK_WORD = {
|
|
|
17
17
|
function textError(text) {
|
|
18
18
|
return { content: [{ type: "text", text: `${APEX} ${text}` }], isError: true };
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* The reason a goal event can't be used in this workspace, or null when it
|
|
22
|
+
* can. Same check the experiments API enforces on create and edit; when the
|
|
23
|
+
* check itself can't be reached, the API's own refusal still applies.
|
|
24
|
+
*/
|
|
25
|
+
async function refuseGoalEvent(event) {
|
|
26
|
+
let check;
|
|
27
|
+
try {
|
|
28
|
+
check = await apiGet(`/api/experiments/goal-check?event=${encodeURIComponent(event)}`);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (check.data?.accepted !== false)
|
|
34
|
+
return null;
|
|
35
|
+
const suggestions = check.data.suggestions ?? [];
|
|
36
|
+
return [
|
|
37
|
+
check.error ?? `"${event}" isn't an event this workspace receives or defined.`,
|
|
38
|
+
suggestions.length ? `Closest events here: ${suggestions.join(", ")}.` : "Call list_events to see what this workspace receives.",
|
|
39
|
+
].join(" ");
|
|
40
|
+
}
|
|
20
41
|
async function createAdHoldout(args) {
|
|
21
42
|
if (args.surface && args.surface !== "paid_channel") {
|
|
22
43
|
return textError(`paid_channel only applies to surface "paid_channel" (got "${args.surface}").`);
|
|
@@ -26,9 +47,10 @@ async function createAdHoldout(args) {
|
|
|
26
47
|
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.`);
|
|
27
48
|
}
|
|
28
49
|
const networkWord = NETWORK_WORD[pc.network] ?? pc.network;
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
const apexAttaches = pc.network === "meta" || pc.network === "tiktok";
|
|
51
|
+
const attachMode = pc.attach_mode ?? (apexAttaches ? "apex" : "manual");
|
|
52
|
+
if (attachMode === "apex" && !apexAttaches) {
|
|
53
|
+
return textError(`Apex can put the list on the ad sets itself on Meta and TikTok only. For ${networkWord} pass attach_mode "manual".`);
|
|
32
54
|
}
|
|
33
55
|
const adSetIds = [...new Set(pc.ad_set_ids.map((s) => s.trim()).filter(Boolean))];
|
|
34
56
|
if (adSetIds.length === 0)
|
|
@@ -317,7 +339,7 @@ export const experimentTools = {
|
|
|
317
339
|
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."),
|
|
318
340
|
beliefConfidence: z.number().min(0).max(100).optional().describe("Confidence (0-100%) for the belief created from beliefStatement. Defaults to 50."),
|
|
319
341
|
hypothesisStatement: z.string().optional().describe("Optional hypothesis statement to record alongside a belief created from beliefStatement."),
|
|
320
|
-
primaryMetricEvent: z.string().optional().describe("
|
|
342
|
+
primaryMetricEvent: z.string().optional().describe("Event the experiment optimizes: a standard Apex event (e.g. 'checkout_completed') or any event this workspace receives or defined with define_event (e.g. 'demo_booked'). Checked against this workspace; a name it has never seen is refused with the closest matches. Prefer an OUTCOME event (purchase/signup) over a proxy (email_open) — use list_metrics to see which are DETECTED with volume."),
|
|
321
343
|
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."),
|
|
322
344
|
primaryMetricValueProperty: z.string().optional().describe("For revenue/duration goals: the event property to sum, e.g. 'value'. Defaults to 'value'."),
|
|
323
345
|
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."),
|
|
@@ -327,11 +349,11 @@ export const experimentTools = {
|
|
|
327
349
|
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 workspace'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)."),
|
|
328
350
|
paid_channel: z
|
|
329
351
|
.object({
|
|
330
|
-
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
|
|
352
|
+
network: z.enum(["meta", "google", "tiktok", "linkedin"]).describe("Ad network the ad sets run on (TikTok: ad group ids). Apex attaches the do-not-show list itself on Meta and TikTok; Google and LinkedIn are manual attach."),
|
|
331
353
|
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."),
|
|
332
354
|
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."),
|
|
333
355
|
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)."),
|
|
334
|
-
attach_mode: z.enum(["apex", "manual"]).optional().describe("'apex' = Apex puts the list on the ad sets and checks daily (Meta
|
|
356
|
+
attach_mode: z.enum(["apex", "manual"]).optional().describe("'apex' = Apex puts the list on the ad sets and checks daily (Meta and TikTok). 'manual' = the owner adds the audience as an exclusion by hand; Apex checks daily and counts nothing until it is on. Defaults to 'apex' on Meta and TikTok, 'manual' elsewhere."),
|
|
335
357
|
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."),
|
|
336
358
|
population_segment_id: z.string().optional().describe("Optional Segment id narrowing who can be drawn."),
|
|
337
359
|
})
|
|
@@ -416,31 +438,9 @@ export const experimentTools = {
|
|
|
416
438
|
};
|
|
417
439
|
if (args.primaryMetricEvent) {
|
|
418
440
|
const event = args.primaryMetricEvent.trim();
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
validEvents = Object.keys(spec.data?.events ?? {});
|
|
423
|
-
}
|
|
424
|
-
catch (err) {
|
|
425
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
426
|
-
return {
|
|
427
|
-
content: [{
|
|
428
|
-
type: "text",
|
|
429
|
-
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).`,
|
|
430
|
-
}],
|
|
431
|
-
isError: true,
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
if (validEvents.length && !validEvents.includes(event)) {
|
|
435
|
-
const sample = validEvents.slice(0, 12).join(", ");
|
|
436
|
-
return {
|
|
437
|
-
content: [{
|
|
438
|
-
type: "text",
|
|
439
|
-
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.`,
|
|
440
|
-
}],
|
|
441
|
-
isError: true,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
441
|
+
const refused = await refuseGoalEvent(event);
|
|
442
|
+
if (refused)
|
|
443
|
+
return textError(refused);
|
|
444
444
|
const metricType = args.primaryMetricType ?? "rate";
|
|
445
445
|
const isValue = metricType === "revenue" || metricType === "duration";
|
|
446
446
|
const valueProperty = args.primaryMetricValueProperty ?? "value";
|
|
@@ -1660,7 +1660,7 @@ export const experimentTools = {
|
|
|
1660
1660
|
},
|
|
1661
1661
|
},
|
|
1662
1662
|
attach_ad_exclusion: {
|
|
1663
|
-
description: `${APEX} — Ad holdout only (surface paid_channel, Meta
|
|
1663
|
+
description: `${APEX} — Ad holdout only (surface paid_channel, Meta and TikTok). Apex puts the held-back list on the holdout's ad sets (TikTok: ad groups) 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 workspace 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 workspace confirms, call with preview=false. The first real attach in a workspace needs acknowledge=true, which records the workspace's standing allowance for Apex to edit ad-set targeting (say so before passing it; never pass it without the workspace 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/LinkedIn (there the owner 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.`,
|
|
1664
1664
|
schema: z.object({
|
|
1665
1665
|
experimentId: z.string().describe("The Ad holdout experiment id."),
|
|
1666
1666
|
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."),
|
|
@@ -1686,8 +1686,8 @@ export const experimentTools = {
|
|
|
1686
1686
|
note: a.message ?? null,
|
|
1687
1687
|
}));
|
|
1688
1688
|
const reasonWords = {
|
|
1689
|
-
no_connection: "
|
|
1690
|
-
list_not_on_network: "The held-back list has not reached
|
|
1689
|
+
no_connection: "The ad platform is not connected with outbound access.",
|
|
1690
|
+
list_not_on_network: "The held-back list has not reached the ad platform yet. Activate the holdout and wait for the sync; then attach.",
|
|
1691
1691
|
adsets_not_ready: "At least one ad set could not be read or is on another ad account. Apex will not touch those.",
|
|
1692
1692
|
};
|
|
1693
1693
|
return {
|
|
@@ -1715,10 +1715,10 @@ export const experimentTools = {
|
|
|
1715
1715
|
const r = await apiPost(path, args.acknowledge ? { acknowledge: true } : {});
|
|
1716
1716
|
const d = r.data;
|
|
1717
1717
|
const words = {
|
|
1718
|
-
attached: "The list is on every ad set and
|
|
1719
|
-
syncing: "Apex put the list on every ad set. It is checking
|
|
1718
|
+
attached: "The list is on every ad set and the ad platform confirmed it. Counting starts after the washout.",
|
|
1719
|
+
syncing: "Apex put the list on every ad set. It is checking the ad platform has taken it; verify_ad_exclusion reads the answer.",
|
|
1720
1720
|
failed_verify: "The list is on some ad sets, not all. See the changes below and last_error.",
|
|
1721
|
-
token_expired: "
|
|
1721
|
+
token_expired: "The ad platform no longer accepts Apex's connection. Sign in to it again and attach again.",
|
|
1722
1722
|
};
|
|
1723
1723
|
return {
|
|
1724
1724
|
content: [{
|
|
@@ -1740,7 +1740,7 @@ export const experimentTools = {
|
|
|
1740
1740
|
})),
|
|
1741
1741
|
last_error: d.lastVerifyError ?? null,
|
|
1742
1742
|
_next: d.allSettled
|
|
1743
|
-
? `verify_ad_exclusion({ experiment_id: "${id}" }) once
|
|
1743
|
+
? `verify_ad_exclusion({ experiment_id: "${id}" }) once the ad platform has had a minute, or get_results after the washout.`
|
|
1744
1744
|
: "Read each change's note; a rate-limited or locked ad set can simply be attached again.",
|
|
1745
1745
|
}, null, 2),
|
|
1746
1746
|
}],
|
|
@@ -1752,14 +1752,14 @@ export const experimentTools = {
|
|
|
1752
1752
|
return textError(`Apex will not edit ad sets until the owner has allowed it. Call attach_ad_exclusion with preview=true, show the before/after, and on the owner's word call again with preview=false and acknowledge=true.`);
|
|
1753
1753
|
}
|
|
1754
1754
|
if (/network_not_supported/.test(message)) {
|
|
1755
|
-
return textError("Apex attaches the list itself on Meta only. On other networks the owner adds the list in the network's own tools; verify_ad_exclusion checks it is on.");
|
|
1755
|
+
return textError("Apex attaches the list itself on Meta and TikTok only. On other networks the owner adds the list in the network's own tools; verify_ad_exclusion checks it is on.");
|
|
1756
1756
|
}
|
|
1757
1757
|
return textError(`Could not attach the held-back list: ${message}`);
|
|
1758
1758
|
}
|
|
1759
1759
|
},
|
|
1760
1760
|
},
|
|
1761
1761
|
detach_ad_exclusion: {
|
|
1762
|
-
description: `${APEX} — Ad holdout only (surface paid_channel, Meta
|
|
1762
|
+
description: `${APEX} — Ad holdout only (surface paid_channel, Meta and TikTok). Take Apex's exclusion back off the holdout's ad sets and delete the held-back list from the ad platform. Removes only what Apex added: a list the owner attached by hand is refused (not_apex_attached) and the owner 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 workspace first: the held-back people start seeing the ads again and the holdout stops measuring.`,
|
|
1763
1763
|
schema: z.object({
|
|
1764
1764
|
experimentId: z.string().describe("The Ad holdout experiment id."),
|
|
1765
1765
|
}),
|
|
@@ -1806,11 +1806,12 @@ export const experimentTools = {
|
|
|
1806
1806
|
hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
|
|
1807
1807
|
targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
|
|
1808
1808
|
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."),
|
|
1809
|
-
primaryMetricEvent: z.string().optional().describe("Change the primary metric's
|
|
1809
|
+
primaryMetricEvent: z.string().optional().describe("Change the primary metric's event — draft only. Same check as create_experiment: a standard Apex event, or any event this workspace receives or defined."),
|
|
1810
1810
|
primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
|
|
1811
1811
|
guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
|
|
1812
1812
|
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)."),
|
|
1813
1813
|
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)."),
|
|
1814
|
+
allocation: z.enum(["even", "adaptive"]).optional().describe("Journey message tests (surface journey_arm) only, draft only. 'even' keeps every version on an equal share for the whole test (the default, cleanest read on the difference). 'adaptive' starts even, then once every version has reached the minimum per version, sends more people to the version that is ahead. Ask the owner which they want; do not pick for them."),
|
|
1814
1815
|
}),
|
|
1815
1816
|
handler: async (args) => {
|
|
1816
1817
|
if (args.targetUrl) {
|
|
@@ -1823,6 +1824,25 @@ export const experimentTools = {
|
|
|
1823
1824
|
}
|
|
1824
1825
|
}
|
|
1825
1826
|
const body = { id: args.experimentId };
|
|
1827
|
+
if (args.allocation !== undefined) {
|
|
1828
|
+
let existing;
|
|
1829
|
+
try {
|
|
1830
|
+
existing = await apiGet(`/api/experiments/${args.experimentId}?unified=true`);
|
|
1831
|
+
}
|
|
1832
|
+
catch (err) {
|
|
1833
|
+
return textError(`Could not read the experiment: ${errMsg(err)}`);
|
|
1834
|
+
}
|
|
1835
|
+
if (existing.surface !== "journey_arm") {
|
|
1836
|
+
return textError("allocation only applies to journey message tests. Website and app experiments keep their own split.");
|
|
1837
|
+
}
|
|
1838
|
+
if (existing.status !== "draft") {
|
|
1839
|
+
return textError("The split is part of the plan and locks when the test starts. Fork the experiment to change it.");
|
|
1840
|
+
}
|
|
1841
|
+
body.allocation = {
|
|
1842
|
+
...existing.allocation,
|
|
1843
|
+
strategy: args.allocation === "adaptive" ? "thompson" : "even",
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1826
1846
|
if (args.name !== undefined)
|
|
1827
1847
|
body.name = args.name;
|
|
1828
1848
|
if (args.surface !== undefined)
|
|
@@ -1844,6 +1864,9 @@ export const experimentTools = {
|
|
|
1844
1864
|
body.evaluationWindow = ew;
|
|
1845
1865
|
}
|
|
1846
1866
|
if (args.primaryMetricEvent) {
|
|
1867
|
+
const refused = await refuseGoalEvent(args.primaryMetricEvent.trim());
|
|
1868
|
+
if (refused)
|
|
1869
|
+
return textError(refused);
|
|
1847
1870
|
const t = args.primaryMetricType ?? "rate";
|
|
1848
1871
|
const isValue = t === "revenue" || t === "duration";
|
|
1849
1872
|
body.primaryMetric = {
|