@apex-inc/mcp-server 0.14.0 → 0.16.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/index.js +0 -0
- package/dist/tools.d.ts +343 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +562 -4
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-experimentation/SKILL.md +2 -0
- package/skills/apex-growth-intelligence/SKILL.md +53 -0
- package/skills/apex-integration-cookbook/SKILL.md +77 -0
- package/skills/apex-spec/SKILL.md +52 -4
package/dist/tools.js
CHANGED
|
@@ -64,6 +64,28 @@ function appUrl(path) {
|
|
|
64
64
|
function journeyLink(id) {
|
|
65
65
|
return appUrl(`/dashboard/communications/journeys/${id}`);
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Render the author-time conflict guard advisory (non-blocking). `selfId` is the
|
|
69
|
+
* candidate experiment's id when known (so the set_experiment_mutex suggestion
|
|
70
|
+
* is copy-pasteable); omitted during pre-create previews.
|
|
71
|
+
*/
|
|
72
|
+
function renderConflictAdvisory(data, selfId) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (const h of data.differentMetric) {
|
|
75
|
+
const soft = h.confidence === "low" ? " (same data source — only a real conflict if it targets the same screen)" : "";
|
|
76
|
+
lines.push(`⚠ Overlaps "${h.name}" (${h.experimentId}) on ${h.location}${soft}. It measures ${h.primaryMetricLabel}; this measures a different goal — running both at once contaminates both results.`);
|
|
77
|
+
lines.push(selfId
|
|
78
|
+
? ` → Run both safely in parallel: set_experiment_mutex({ experimentId: "${selfId}", withExperimentId: "${h.experimentId}" }). Or sequence: activate this only after "${h.name}" concludes.`
|
|
79
|
+
: ` → After creating it, run both safely in parallel: set_experiment_mutex({ experimentId: "<new id>", withExperimentId: "${h.experimentId}" }). Or sequence after "${h.name}" concludes.`);
|
|
80
|
+
}
|
|
81
|
+
for (const h of data.sameMetric) {
|
|
82
|
+
const soft = h.confidence === "low" ? " (same data source — only a real conflict if it targets the same screen)" : "";
|
|
83
|
+
lines.push(`↔ "${h.name}" (${h.experimentId}) already tests ${h.location} for ${h.primaryMetricLabel} — the SAME metric${soft}. These look like two arms of one question: consider adding your change as a variant of "${h.name}" rather than a separate experiment.`);
|
|
84
|
+
}
|
|
85
|
+
if (lines.length === 0)
|
|
86
|
+
return "✓ No overlap with live experiments on this surface.";
|
|
87
|
+
return `Conflict guard:\n${lines.join("\n")}`;
|
|
88
|
+
}
|
|
67
89
|
/**
|
|
68
90
|
* Detect a MOBILE experiment surface from repo signals (the MCP runs in the
|
|
69
91
|
* merchant's repo). Walks up a few dirs looking for capacitor.config.* or a
|
|
@@ -495,6 +517,25 @@ export const toolDefinitions = {
|
|
|
495
517
|
};
|
|
496
518
|
}
|
|
497
519
|
if (isPreview) {
|
|
520
|
+
// Author-time conflict guard (best-effort, non-blocking): warn if this
|
|
521
|
+
// overlaps a LIVE experiment on the same surface before it's created.
|
|
522
|
+
let conflictAdvisory;
|
|
523
|
+
try {
|
|
524
|
+
const cc = await apiPost("/api/experiments/check-conflicts", {
|
|
525
|
+
surface: experimentSurface,
|
|
526
|
+
target_component: args.targetComponent,
|
|
527
|
+
target_url: args.targetUrl,
|
|
528
|
+
target_anchor: args.targetAnchor,
|
|
529
|
+
data_source_id: args.dataSourceId,
|
|
530
|
+
primary_metric_event: primaryMetric.source.eventType,
|
|
531
|
+
});
|
|
532
|
+
if (cc.data.sameMetric.length || cc.data.differentMetric.length) {
|
|
533
|
+
conflictAdvisory = renderConflictAdvisory(cc.data);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
/* conflict check is advisory; never block the preview */
|
|
538
|
+
}
|
|
498
539
|
const recipe = {
|
|
499
540
|
_apex: true,
|
|
500
541
|
_type: "experiment_preview",
|
|
@@ -511,7 +552,8 @@ export const toolDefinitions = {
|
|
|
511
552
|
beliefId: args.beliefId || null,
|
|
512
553
|
beliefStatement: args.beliefStatement || null,
|
|
513
554
|
predictionId: args.predictionId || null,
|
|
514
|
-
|
|
555
|
+
...(conflictAdvisory ? { _conflict_advisory: conflictAdvisory } : {}),
|
|
556
|
+
_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." : ""} 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.`,
|
|
515
557
|
};
|
|
516
558
|
return { content: [{ type: "text", text: JSON.stringify(recipe, null, 2) }] };
|
|
517
559
|
}
|
|
@@ -602,6 +644,9 @@ export const toolDefinitions = {
|
|
|
602
644
|
name: args.name,
|
|
603
645
|
targetUrl: args.targetUrl,
|
|
604
646
|
targetAnchor: args.targetAnchor,
|
|
647
|
+
// Persist the component (e.g. "ProductCard") as a precise location
|
|
648
|
+
// signal for the author-time conflict guard — esp. mobile/SDK arms.
|
|
649
|
+
...(args.targetComponent ? { targetComponent: args.targetComponent } : {}),
|
|
605
650
|
createdFrom: "cursor",
|
|
606
651
|
status: "draft",
|
|
607
652
|
...(args.dataSourceId ? { dataSourceId: args.dataSourceId } : {}),
|
|
@@ -978,6 +1023,17 @@ export const toolDefinitions = {
|
|
|
978
1023
|
else if (wiring && !wiring.bothArmsLive && force === true) {
|
|
979
1024
|
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.`, ``);
|
|
980
1025
|
}
|
|
1026
|
+
// Author-time conflict guard (advisory): if this now-running experiment
|
|
1027
|
+
// overlaps another live one on the same surface, surface the fix.
|
|
1028
|
+
try {
|
|
1029
|
+
const cc = await apiPost("/api/experiments/check-conflicts", { experiment_id: experimentId });
|
|
1030
|
+
if (cc.data.sameMetric.length || cc.data.differentMetric.length) {
|
|
1031
|
+
notes.push(` ${renderConflictAdvisory(cc.data, experimentId).split("\n").join("\n ")}`, ``);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
/* advisory only — never affect the launch result */
|
|
1036
|
+
}
|
|
981
1037
|
return {
|
|
982
1038
|
content: [
|
|
983
1039
|
{
|
|
@@ -1445,6 +1501,53 @@ export const toolDefinitions = {
|
|
|
1445
1501
|
}
|
|
1446
1502
|
},
|
|
1447
1503
|
},
|
|
1504
|
+
check_experiment_conflicts: {
|
|
1505
|
+
description: `${APEX} — Before you create or activate an experiment, check whether it overlaps a LIVE experiment on the same surface. Same location + DIFFERENT metric → they'll contaminate each other (make them mutually exclusive with set_experiment_mutex, or sequence). Same location + SAME metric → add a variant to the existing experiment instead. Pass either experimentId (an existing draft) OR the candidate's location + primaryMetricEvent. Advisory only — nothing is blocked.`,
|
|
1506
|
+
schema: z.object({
|
|
1507
|
+
experimentId: z.string().optional().describe("Check an existing experiment by id (loads its location + metric)."),
|
|
1508
|
+
surface: z.enum(["web", "mobile"]).optional(),
|
|
1509
|
+
targetComponent: z.string().optional().describe("Component/file the experiment changes, e.g. 'ProductCard'."),
|
|
1510
|
+
targetUrl: z.string().optional(),
|
|
1511
|
+
targetAnchor: z.string().optional(),
|
|
1512
|
+
dataSourceId: z.string().optional(),
|
|
1513
|
+
primaryMetricEvent: z.string().optional().describe("Canonical primary-metric event, e.g. 'add_to_cart'."),
|
|
1514
|
+
}),
|
|
1515
|
+
handler: async (args) => {
|
|
1516
|
+
try {
|
|
1517
|
+
const res = await apiPost("/api/experiments/check-conflicts", {
|
|
1518
|
+
...(args.experimentId ? { experiment_id: args.experimentId } : {}),
|
|
1519
|
+
surface: args.surface,
|
|
1520
|
+
target_component: args.targetComponent,
|
|
1521
|
+
target_url: args.targetUrl,
|
|
1522
|
+
target_anchor: args.targetAnchor,
|
|
1523
|
+
data_source_id: args.dataSourceId,
|
|
1524
|
+
primary_metric_event: args.primaryMetricEvent,
|
|
1525
|
+
});
|
|
1526
|
+
return { content: [{ type: "text", text: `${APEX} ${renderConflictAdvisory(res.data, args.experimentId)}` }] };
|
|
1527
|
+
}
|
|
1528
|
+
catch (err) {
|
|
1529
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
1530
|
+
}
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
set_experiment_mutex: {
|
|
1534
|
+
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.`,
|
|
1535
|
+
schema: z.object({
|
|
1536
|
+
experimentId: z.string(),
|
|
1537
|
+
withExperimentId: z.string().describe("The other experiment to make mutually exclusive with."),
|
|
1538
|
+
mode: z.enum(["add", "remove"]).optional().describe("'add' (default) links; 'remove' unlinks."),
|
|
1539
|
+
}),
|
|
1540
|
+
handler: async ({ experimentId, withExperimentId, mode }) => {
|
|
1541
|
+
try {
|
|
1542
|
+
await apiPost(`/api/experiments/${encodeURIComponent(experimentId)}/mutex`, { withExperimentId, mode });
|
|
1543
|
+
const verb = mode === "remove" ? "are no longer" : "are now";
|
|
1544
|
+
return { content: [{ type: "text", text: `${APEX} ${experimentId} and ${withExperimentId} ${verb} mutually exclusive — no visitor will be assigned to both.` }] };
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
|
|
1548
|
+
}
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1448
1551
|
get_growth_reality: {
|
|
1449
1552
|
description: `${APEX} — "Is our growth real?" Per goal, the cumulative lift of everything Apex did (journeys + experiments) vs a persistent do-nothing global holdout, with a 95% CI. The honest, holdout-gated answer to "would this have happened anyway?" Forward-only; goals still collecting are flagged, not faked.`,
|
|
1450
1553
|
schema: z.object({
|
|
@@ -1496,6 +1599,283 @@ export const toolDefinitions = {
|
|
|
1496
1599
|
};
|
|
1497
1600
|
},
|
|
1498
1601
|
},
|
|
1602
|
+
get_channel_economics: {
|
|
1603
|
+
description: `${APEX} — "Where should my next dollar go?" Per acquisition channel (raw first-touch): gross-margin-adjusted LTV:CAC, payback months, CAC, and customers, split into Acquisition (paid) vs Leverage (owned/organic, no spend). Computed from real contacts + spend + revenue — metrics with missing inputs are shown as "—", never fabricated. Free intelligence; the verdict is never gated.`,
|
|
1604
|
+
schema: z.object({
|
|
1605
|
+
days: z
|
|
1606
|
+
.union([z.number(), z.literal("all")])
|
|
1607
|
+
.optional()
|
|
1608
|
+
.describe("Trailing window in days, or 'all' for since-inception (default)."),
|
|
1609
|
+
}),
|
|
1610
|
+
handler: async ({ days }) => {
|
|
1611
|
+
const q = days && days !== "all" ? `?days=${days}` : "?days=all";
|
|
1612
|
+
const res = await apiGet(`/api/channels/economics${q}`);
|
|
1613
|
+
const channels = res.data?.channels ?? [];
|
|
1614
|
+
if (channels.length === 0) {
|
|
1615
|
+
return {
|
|
1616
|
+
content: [
|
|
1617
|
+
{
|
|
1618
|
+
type: "text",
|
|
1619
|
+
text: `${APEX} No channel economics yet — connect attributed traffic (Apex Links auto-tag it) and revenue so acquisition channels can be measured.`,
|
|
1620
|
+
},
|
|
1621
|
+
],
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
const label = (slug) => ({
|
|
1625
|
+
linkedin: "LinkedIn Ads",
|
|
1626
|
+
google: "Google",
|
|
1627
|
+
meta: "Meta",
|
|
1628
|
+
microsoft: "Microsoft Ads",
|
|
1629
|
+
tiktok: "TikTok",
|
|
1630
|
+
twitter: "Twitter / X",
|
|
1631
|
+
direct: "Direct",
|
|
1632
|
+
email: "Email",
|
|
1633
|
+
affiliate: "Partner Network",
|
|
1634
|
+
})[slug] ?? slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
1635
|
+
const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
|
|
1636
|
+
const ratio = (r) => (r == null ? "—" : `${r.toFixed(2)}x`);
|
|
1637
|
+
const ranked = [...channels].sort((a, b) => (b.ltvCacRatio ?? -1) - (a.ltvCacRatio ?? -1));
|
|
1638
|
+
const acquisition = ranked.filter((c) => c.spend !== null && c.spend > 0);
|
|
1639
|
+
const leverage = ranked.filter((c) => c.spend === null || c.spend === 0);
|
|
1640
|
+
const fmt = (c) => ` • ${label(c.channel)}: ${ratio(c.ltvCacRatio)} LTV:CAC · payback ${c.paybackMonths != null ? `${c.paybackMonths}mo` : "—"} · CAC ${money(c.cac)} · ${c.customers.toLocaleString()} customers`;
|
|
1641
|
+
const lines = [
|
|
1642
|
+
`${APEX} Channel economics — gross-margin LTV:CAC`,
|
|
1643
|
+
"═".repeat(40),
|
|
1644
|
+
];
|
|
1645
|
+
if (res.data?.needsReferee) {
|
|
1646
|
+
lines.push("Revenue figures hidden: two live revenue sources with no referee picked. Choose one in Connect revenue.", "");
|
|
1647
|
+
}
|
|
1648
|
+
if (acquisition.length > 0) {
|
|
1649
|
+
lines.push("Acquisition (paid):", ...acquisition.map(fmt), "");
|
|
1650
|
+
}
|
|
1651
|
+
if (leverage.length > 0) {
|
|
1652
|
+
lines.push("Leverage (owned / organic):", ...leverage.map(fmt), "");
|
|
1653
|
+
}
|
|
1654
|
+
lines.push("3-5x LTV:CAC is healthy; <1x loses margin on every customer; >5x is likely underinvested.");
|
|
1655
|
+
if (res.data?.grossMarginFromDefault) {
|
|
1656
|
+
lines.push(`(Using a default ${res.data.grossMargin != null
|
|
1657
|
+
? `${Math.round(res.data.grossMargin * 100)}%`
|
|
1658
|
+
: ""} gross margin — set your real margin in workspace settings to sharpen this.)`);
|
|
1659
|
+
}
|
|
1660
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1661
|
+
},
|
|
1662
|
+
},
|
|
1663
|
+
// ─── Vertical Widget Packs read tools (2026-07-02) ─────────────────────
|
|
1664
|
+
// One tool per pack read API. All aggregate-only (analytics:read scope);
|
|
1665
|
+
// small-n suppression applies to API responses exactly as to widgets.
|
|
1666
|
+
get_saas_recurring_revenue: {
|
|
1667
|
+
description: `${APEX} — SaaS recurring-revenue economics: MRR, ARR, the monthly waterfall (new / expansion / reactivation / contraction / churn — accounting-identity tested), and NRR/GRR for the last complete month. Computed from materialized monthly snapshots (calendar months, UTC); the current month is flagged in-progress. NRR/GRR read "collecting history" until 13 months of snapshots exist — never fabricated.`,
|
|
1668
|
+
schema: z.object({
|
|
1669
|
+
months: z
|
|
1670
|
+
.number()
|
|
1671
|
+
.optional()
|
|
1672
|
+
.describe("Months of waterfall history to include (2-36, default 13)."),
|
|
1673
|
+
}),
|
|
1674
|
+
handler: async ({ months }) => {
|
|
1675
|
+
const q = months ? `?months=${months}` : "";
|
|
1676
|
+
const res = await apiGet(`/api/saas/recurring-revenue${q}`);
|
|
1677
|
+
const d = res.data;
|
|
1678
|
+
if (!d?.hasData) {
|
|
1679
|
+
return {
|
|
1680
|
+
content: [
|
|
1681
|
+
{
|
|
1682
|
+
type: "text",
|
|
1683
|
+
text: `${APEX} No MRR state yet — connect Stripe or send subscription events (subscription_started / subscription_event with amount + period_type) and monthly snapshots build from there.`,
|
|
1684
|
+
},
|
|
1685
|
+
],
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
|
|
1689
|
+
const pct = (r) => r == null ? "collecting history" : `${Math.round(r * 1000) / 10}%`;
|
|
1690
|
+
const lines = [
|
|
1691
|
+
`${APEX} Recurring revenue`,
|
|
1692
|
+
"═".repeat(40),
|
|
1693
|
+
`MRR ${money(d.currentMrr)} (month in progress) · ARR ${money(d.currentArr)} · ${d.payingAccounts ?? "—"} paying accounts`,
|
|
1694
|
+
`NRR ${pct(d.retention?.nrr ?? null)} · GRR ${pct(d.retention?.grr ?? null)} (trailing 12mo, ${d.retention?.cohortSize ?? 0}-account cohort)`,
|
|
1695
|
+
"",
|
|
1696
|
+
"Waterfall (last months):",
|
|
1697
|
+
...d.waterfall
|
|
1698
|
+
.slice(-6)
|
|
1699
|
+
.map((m) => ` • ${m.month}${m.inProgress ? " (in progress)" : ""}: ${money(m.startMrr)} → ${money(m.endMrr)} (+${money(m.newMrr)} new, +${money(m.expansionMrr)} expansion, +${money(m.reactivationMrr)} reactivation, −${money(m.contractionMrr)} contraction, −${money(m.churnedMrr)} churn)`),
|
|
1700
|
+
];
|
|
1701
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1702
|
+
},
|
|
1703
|
+
},
|
|
1704
|
+
get_saas_expansion: {
|
|
1705
|
+
description: `${APEX} — SaaS expansion economics: expansion vs contraction MRR for the last complete month, accounts expanding (counts — small-n honest), and seat growth from seat_count. Metrics with missing inputs come back null, never fabricated.`,
|
|
1706
|
+
schema: z.object({
|
|
1707
|
+
months: z
|
|
1708
|
+
.number()
|
|
1709
|
+
.optional()
|
|
1710
|
+
.describe("Months of series history to include (2-36, default 13)."),
|
|
1711
|
+
}),
|
|
1712
|
+
handler: async ({ months }) => {
|
|
1713
|
+
const q = months ? `?months=${months}` : "";
|
|
1714
|
+
const res = await apiGet(`/api/saas/expansion${q}`);
|
|
1715
|
+
const d = res.data;
|
|
1716
|
+
if (!d?.hasData) {
|
|
1717
|
+
return {
|
|
1718
|
+
content: [
|
|
1719
|
+
{
|
|
1720
|
+
type: "text",
|
|
1721
|
+
text: `${APEX} No subscription data yet — expansion metrics unlock once MRR snapshots exist.`,
|
|
1722
|
+
},
|
|
1723
|
+
],
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
|
|
1727
|
+
const seatsLine = d.seatCount != null
|
|
1728
|
+
? `Seats: ${d.seatCount}${d.seatCountPrevMonth != null ? ` (${d.seatCount - d.seatCountPrevMonth >= 0 ? "+" : ""}${d.seatCount - d.seatCountPrevMonth} vs last month)` : ""}`
|
|
1729
|
+
: "Seats: — (send seat_count on subscription events to unlock)";
|
|
1730
|
+
return {
|
|
1731
|
+
content: [
|
|
1732
|
+
{
|
|
1733
|
+
type: "text",
|
|
1734
|
+
text: [
|
|
1735
|
+
`${APEX} Expansion (last complete month)`,
|
|
1736
|
+
"═".repeat(40),
|
|
1737
|
+
`Expansion MRR ${money(d.expansionMrrLastMonth)} · Contraction MRR ${money(d.contractionMrrLastMonth)}`,
|
|
1738
|
+
`Accounts expanding: ${d.expandedAccounts ?? "—"} of ${d.payingAccountsAtStart ?? "—"} paying at month start`,
|
|
1739
|
+
seatsLine,
|
|
1740
|
+
].join("\n"),
|
|
1741
|
+
},
|
|
1742
|
+
],
|
|
1743
|
+
};
|
|
1744
|
+
},
|
|
1745
|
+
},
|
|
1746
|
+
get_dtc_merchandising: {
|
|
1747
|
+
description: `${APEX} — DTC merchandising: top products by revenue with the per-product view → add-to-cart → purchase funnel and refund counts. Products need a product_id on commerce events; never-wired signals are reported as unwired, not zero.`,
|
|
1748
|
+
schema: z.object({
|
|
1749
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 180)."),
|
|
1750
|
+
}),
|
|
1751
|
+
handler: async ({ days }) => {
|
|
1752
|
+
const q = days ? `?days=${days}` : "";
|
|
1753
|
+
const res = await apiGet(`/api/dtc/merchandising${q}`);
|
|
1754
|
+
const d = res.data;
|
|
1755
|
+
if (!d?.hasData) {
|
|
1756
|
+
return {
|
|
1757
|
+
content: [
|
|
1758
|
+
{
|
|
1759
|
+
type: "text",
|
|
1760
|
+
text: `${APEX} No product-scoped events yet — fire product_view / add_to_cart / purchases with a product_id.`,
|
|
1761
|
+
},
|
|
1762
|
+
],
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
const money = (n) => `$${Math.round(n).toLocaleString()}`;
|
|
1766
|
+
const lines = [
|
|
1767
|
+
`${APEX} Merchandising — ${money(d.totals.revenue)} product revenue, ${d.totals.purchases} purchases`,
|
|
1768
|
+
"═".repeat(40),
|
|
1769
|
+
...d.items
|
|
1770
|
+
.slice(0, 10)
|
|
1771
|
+
.map((i) => ` • ${i.label ?? i.itemKey}${i.category ? ` [${i.category}]` : ""}: ${money(i.revenue)} · ${i.views} views → ${i.adds} adds → ${i.purchases} purchases${i.refunds > 0 ? ` · ${i.refunds} refunds` : ""}`),
|
|
1772
|
+
];
|
|
1773
|
+
if (!d.wired.views)
|
|
1774
|
+
lines.push("(product_view not wired — funnel rates unavailable)");
|
|
1775
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1776
|
+
},
|
|
1777
|
+
},
|
|
1778
|
+
get_dtc_returns: {
|
|
1779
|
+
description: `${APEX} — DTC returns & refunds: refund rate (money back, order basis), return rate (goods back — separate metric, never summed), reason breakdown, refund cycle time, AOV, and repeat purchase. Refund data lands automatically from Stripe; return_requested / return_completed events power the goods-back story.`,
|
|
1780
|
+
schema: z.object({
|
|
1781
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 180)."),
|
|
1782
|
+
}),
|
|
1783
|
+
handler: async ({ days }) => {
|
|
1784
|
+
const q = days ? `?days=${days}` : "";
|
|
1785
|
+
const res = await apiGet(`/api/dtc/returns${q}`);
|
|
1786
|
+
const d = res.data;
|
|
1787
|
+
if (!d?.hasData) {
|
|
1788
|
+
return {
|
|
1789
|
+
content: [
|
|
1790
|
+
{
|
|
1791
|
+
type: "text",
|
|
1792
|
+
text: `${APEX} No order data in the window yet — connect Stripe or send purchase events.`,
|
|
1793
|
+
},
|
|
1794
|
+
],
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
const lines = [
|
|
1798
|
+
`${APEX} Returns & refunds`,
|
|
1799
|
+
"═".repeat(40),
|
|
1800
|
+
`Refunded orders: ${d.refundedOrders} of ${d.ordersInWindow} ($${Math.round(d.refundedAmount).toLocaleString()})`,
|
|
1801
|
+
`Returns (goods back): ${d.returnsCompleted ?? "— (wire return_completed to unlock)"}`,
|
|
1802
|
+
`Refund cycle: ${d.refundCycleHours != null ? `${d.refundCycleHours}h median` : "—"} · AOV: ${d.aov != null ? `$${d.aov}` : "—"} · Repeat: ${d.repeatCustomers} of ${d.purchasingCustomers} customers`,
|
|
1803
|
+
];
|
|
1804
|
+
if (d.reasons.length > 0) {
|
|
1805
|
+
lines.push("", "Reasons:", ...d.reasons.map((r) => ` • ${r.reason}: ${r.count}`));
|
|
1806
|
+
}
|
|
1807
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1808
|
+
},
|
|
1809
|
+
},
|
|
1810
|
+
get_marketplace_metrics: {
|
|
1811
|
+
description: `${APEX} — Marketplace health across six pillars: liquidity (search→transaction, match rate, zero-result searches, unfulfilled demand), supply & demand balance, economics (GMV vs the take — fee_amount is the platform's revenue, GMV never inflates LTV), buyer retention, trust rates, and concentration risk. Sliceable by category / geo / price_band (low-volume slices are suppressed for privacy). Metrics whose events aren't wired come back as unwired hints, never fabricated zeros.`,
|
|
1812
|
+
schema: z.object({
|
|
1813
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 180)."),
|
|
1814
|
+
category: z.string().optional().describe("Slice: listing category."),
|
|
1815
|
+
geo: z.string().optional().describe("Slice: transaction geo."),
|
|
1816
|
+
priceBand: z.string().optional().describe("Slice: price band (e.g. '100-250')."),
|
|
1817
|
+
}),
|
|
1818
|
+
handler: async ({ days, category, geo, priceBand, }) => {
|
|
1819
|
+
const params = new URLSearchParams();
|
|
1820
|
+
if (days)
|
|
1821
|
+
params.set("days", String(days));
|
|
1822
|
+
if (category)
|
|
1823
|
+
params.set("category", category);
|
|
1824
|
+
if (geo)
|
|
1825
|
+
params.set("geo", geo);
|
|
1826
|
+
if (priceBand)
|
|
1827
|
+
params.set("price_band", priceBand);
|
|
1828
|
+
const qs = params.toString();
|
|
1829
|
+
const res = await apiGet(`/api/marketplace/metrics${qs ? `?${qs}` : ""}`);
|
|
1830
|
+
const d = res.data;
|
|
1831
|
+
if (!d?.hasData) {
|
|
1832
|
+
return {
|
|
1833
|
+
content: [
|
|
1834
|
+
{
|
|
1835
|
+
type: "text",
|
|
1836
|
+
text: `${APEX} No marketplace events yet — wire seller_signup, listing_created, search, offer_submitted, and transaction_completed (with fee_amount, buyer_id, seller_id).`,
|
|
1837
|
+
},
|
|
1838
|
+
],
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
if (d.sliceSuppressed) {
|
|
1842
|
+
return {
|
|
1843
|
+
content: [
|
|
1844
|
+
{
|
|
1845
|
+
type: "text",
|
|
1846
|
+
text: `${APEX} That slice has too few participants to report honestly (privacy floor). Broaden the slice.`,
|
|
1847
|
+
},
|
|
1848
|
+
],
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
|
|
1852
|
+
const L = d.liquidity;
|
|
1853
|
+
const lines = [
|
|
1854
|
+
`${APEX} Marketplace metrics`,
|
|
1855
|
+
"═".repeat(40),
|
|
1856
|
+
`Liquidity: ${L.searchersWhoTransacted}/${L.searchers} searchers transacted (7d)` +
|
|
1857
|
+
(L.searchesWithResultCount > 0
|
|
1858
|
+
? ` · ${L.zeroResultSearches}/${L.searchesWithResultCount} zero-result searches`
|
|
1859
|
+
: "") +
|
|
1860
|
+
` · ${L.transactions} transactions / ${L.offers} offers` +
|
|
1861
|
+
(L.medianHoursToMatch != null ? ` · ${L.medianHoursToMatch}h median to match` : "") +
|
|
1862
|
+
` · ${L.buyersUnfulfilled}/${L.buyersWithIntent} buyers unfulfilled`,
|
|
1863
|
+
`Supply & demand: ${d.supplyDemand.activeBuyers} buyers · ${d.supplyDemand.activeSellers} sellers (${d.supplyDemand.sellersWithTransaction} transacting) · ${d.supplyDemand.newListings} new listings`,
|
|
1864
|
+
`Economics: GMV ${money(d.economics.gmv)} (refund-adj ${money(d.economics.refundAdjustedGmv)}) · take ${money(d.economics.netRevenue)}${d.economics.takeRate != null ? ` (${Math.round(d.economics.takeRate * 1000) / 10}%)` : ""} · contribution ${money(d.economics.contributionMargin)} · payouts ${money(d.economics.sellerPayouts)} · AOV ${money(d.economics.aov)}`,
|
|
1865
|
+
d.demandMix
|
|
1866
|
+
? `Demand mix: paid ${money(d.demandMix.paidGmv)} · organic ${money(d.demandMix.organicGmv)} · unattributed ${money(d.demandMix.unattributedGmv)} (buyer first-touch)`
|
|
1867
|
+
: "Demand mix: — (needs identified buyers with acquisition attribution)",
|
|
1868
|
+
`Trust: ${d.trust.canceled} cancels · ${d.trust.refunded} refunds · ${d.trust.disputes} disputes over ${d.trust.transactions} transactions` +
|
|
1869
|
+
(d.trust.averageRating != null ? ` · ${d.trust.averageRating}/5 avg rating` : ""),
|
|
1870
|
+
d.concentration.suppressed
|
|
1871
|
+
? `Concentration: needs ≥5 sellers (currently ${d.concentration.sellerCount}) for an honest read`
|
|
1872
|
+
: `Concentration: top 10% of sellers = ${Math.round((d.concentration.topSellerGmvShare?.top10 ?? 0) * 1000) / 10}% of GMV (${d.concentration.sellerCount} sellers)`,
|
|
1873
|
+
];
|
|
1874
|
+
if (!d.wired.searches)
|
|
1875
|
+
lines.push("(search not wired — liquidity is partial)");
|
|
1876
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1877
|
+
},
|
|
1878
|
+
},
|
|
1499
1879
|
suggest_experiment: {
|
|
1500
1880
|
description: `${APEX} — Get smart experiment suggestions based on context. Analyzes your assumptions, past experiments, and confidence gaps to recommend what to test next.`,
|
|
1501
1881
|
schema: z.object({
|
|
@@ -1976,19 +2356,20 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
1976
2356
|
},
|
|
1977
2357
|
},
|
|
1978
2358
|
identify_user: {
|
|
1979
|
-
description: `${APEX} — Identify a user by email for identity stitching. Works identically to the SDK's identify() and the snippet's apex.identify(). Links an anonymous visitor to a known email/lead.`,
|
|
2359
|
+
description: `${APEX} — Identify a user by email for identity stitching. Works identically to the SDK's identify() and the snippet's apex.identify(). Links an anonymous visitor to a known email/lead. Pass avatarUrl (a canonical attribute) to give the Contact a profile photo that renders across Apex — Customers list, detail page, and Live Customers widget.`,
|
|
1980
2360
|
schema: z.object({
|
|
1981
2361
|
email: z.string().describe("User email address"),
|
|
1982
2362
|
name: z.string().optional().describe("User name"),
|
|
1983
2363
|
company: z.string().optional().describe("Company name"),
|
|
2364
|
+
avatarUrl: z.string().optional().describe("Profile photo URL (https). Maps to the canonical avatar_url attribute; renders across Apex. Validated on write — non-https/invalid URLs are dropped and the UI falls back to initials. From OIDC logins this is the `picture` claim (`photoURL` in Firebase)."),
|
|
1984
2365
|
metadata: z.record(z.unknown()).optional().describe("Additional traits"),
|
|
1985
2366
|
}),
|
|
1986
|
-
handler: async ({ email, name, company, metadata }) => {
|
|
2367
|
+
handler: async ({ email, name, company, avatarUrl, metadata }) => {
|
|
1987
2368
|
await apiPost("/api/identity/stitch", {
|
|
1988
2369
|
visitorId: `mcp-${email}`,
|
|
1989
2370
|
email,
|
|
1990
2371
|
workspaceKey: (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY) || "default",
|
|
1991
|
-
metadata: { name, company, ...metadata, source: "mcp" },
|
|
2372
|
+
metadata: { name, company, avatar_url: avatarUrl, ...metadata, source: "mcp" },
|
|
1992
2373
|
});
|
|
1993
2374
|
return {
|
|
1994
2375
|
content: [{
|
|
@@ -2047,6 +2428,52 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2047
2428
|
};
|
|
2048
2429
|
},
|
|
2049
2430
|
},
|
|
2431
|
+
define_event: {
|
|
2432
|
+
description: `${APEX} — Define a governed custom event in the workspace's Schema (the customer-extensible registry). Promotes one of the merchant's own events to a first-class, typed, versioned definition that behaves like a canonical Apex Spec event (selectable as a journey trigger/goal, resolvable as comm tokens, validated at ingest). Names must be snake_case and CANNOT shadow a canonical Apex Spec event — call get_event_spec first and reuse the canonical event when one fits. Propose the schema from the merchant's observed payloads (get_schema) and codebase, then confirm with them before defining.`,
|
|
2433
|
+
schema: z.object({
|
|
2434
|
+
name: z.string().describe("snake_case event name, not already in the Apex Spec"),
|
|
2435
|
+
description: z.string().describe("One sentence: when this event fires"),
|
|
2436
|
+
fields: z
|
|
2437
|
+
.record(z.string())
|
|
2438
|
+
.describe('Field name -> type. Types: "string" | "number" | "boolean" | "object" | "array"; append "?" for optional (e.g. {"widget_id":"string","width":"number?"})'),
|
|
2439
|
+
scope: z
|
|
2440
|
+
.enum(["workspace", "org"])
|
|
2441
|
+
.optional()
|
|
2442
|
+
.describe("workspace (default) or org (shared across the org's workspaces)"),
|
|
2443
|
+
primary_metric: z
|
|
2444
|
+
.boolean()
|
|
2445
|
+
.optional()
|
|
2446
|
+
.describe("True if this is a conversion/outcome that can be a journey goal"),
|
|
2447
|
+
recommended_transport: z
|
|
2448
|
+
.enum(["client", "server", "either"])
|
|
2449
|
+
.optional()
|
|
2450
|
+
.describe("Where to fire it: server for money-truth, client for intent/engagement"),
|
|
2451
|
+
}),
|
|
2452
|
+
handler: async (args) => {
|
|
2453
|
+
const res = await apiPost("/api/schema/events", {
|
|
2454
|
+
name: args.name,
|
|
2455
|
+
description: args.description,
|
|
2456
|
+
fields: args.fields,
|
|
2457
|
+
scope: args.scope,
|
|
2458
|
+
primaryMetric: args.primaryMetric,
|
|
2459
|
+
recommendedTransport: args.recommendedTransport,
|
|
2460
|
+
});
|
|
2461
|
+
if (res.error) {
|
|
2462
|
+
const hint = res.suggestion
|
|
2463
|
+
? ` Use the canonical event "${res.suggestion}" instead.`
|
|
2464
|
+
: "";
|
|
2465
|
+
return { content: [{ type: "text", text: `Could not define "${args.name}": ${res.error}.${hint}` }] };
|
|
2466
|
+
}
|
|
2467
|
+
return {
|
|
2468
|
+
content: [
|
|
2469
|
+
{
|
|
2470
|
+
type: "text",
|
|
2471
|
+
text: `Defined governed event "${res.name}" (SchemaVer ${res.schemaVer ?? "1-0-0"}, draft). It's now selectable as a trigger/goal and resolvable as comm tokens once published.`,
|
|
2472
|
+
},
|
|
2473
|
+
],
|
|
2474
|
+
};
|
|
2475
|
+
},
|
|
2476
|
+
},
|
|
2050
2477
|
log_prediction: {
|
|
2051
2478
|
description: `${APEX} — Log a prediction about a metric change. Feeds the calibration loop. Before calling, present the metric, expected change, and confidence as options the user can pick from or adjust.`,
|
|
2052
2479
|
schema: z.object({
|
|
@@ -3786,5 +4213,136 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
3786
4213
|
};
|
|
3787
4214
|
},
|
|
3788
4215
|
},
|
|
4216
|
+
// ─── Conversions & Targets (Goals & Conversions Convergence — PR8) ──────────
|
|
4217
|
+
// A Conversion is the merchant-facing definition of a valued action (the
|
|
4218
|
+
// owned-event matcher managed on /dashboard/conversions). A Target is an
|
|
4219
|
+
// aggregate KPI number to hit by a date. "Goal" is retired as a noun.
|
|
4220
|
+
list_conversions: {
|
|
4221
|
+
description: `${APEX} — List the workspace's Conversions (the owned-event definitions of valued actions, e.g. "Purchase", "Signup"). Call this when the user asks what conversions are defined, or before creating an experiment/journey that needs an objective. The one flagged primary drives default experiment objectives, funnel metrics, and scoring.`,
|
|
4222
|
+
schema: z.object({}),
|
|
4223
|
+
handler: async () => {
|
|
4224
|
+
const conversions = await apiGet("/api/conversion-goals");
|
|
4225
|
+
const active = conversions.filter((c) => !c.archivedAt);
|
|
4226
|
+
if (active.length === 0) {
|
|
4227
|
+
return {
|
|
4228
|
+
content: [
|
|
4229
|
+
{
|
|
4230
|
+
type: "text",
|
|
4231
|
+
text: "No conversions defined yet. Use create_conversion to define one (e.g. a custom_event named 'purchase').",
|
|
4232
|
+
},
|
|
4233
|
+
],
|
|
4234
|
+
};
|
|
4235
|
+
}
|
|
4236
|
+
const lines = active.map((c) => `• ${c.name} (${c.id}) — ${c.type}${c.eventName ? ` [${c.eventName}]` : ""}${c.isPrimary ? " ★ primary" : ""}`);
|
|
4237
|
+
return {
|
|
4238
|
+
content: [
|
|
4239
|
+
{
|
|
4240
|
+
type: "text",
|
|
4241
|
+
text: `${active.length} conversion${active.length === 1 ? "" : "s"}:\n\n${lines.join("\n")}`,
|
|
4242
|
+
},
|
|
4243
|
+
],
|
|
4244
|
+
};
|
|
4245
|
+
},
|
|
4246
|
+
},
|
|
4247
|
+
create_conversion: {
|
|
4248
|
+
description: `${APEX} — Define a new Conversion (an owned-event definition of a valued action). Most conversions are a custom_event with an eventName (e.g. "purchase", "signup") matching an Apex Spec event you send via track. Set isPrimary to make it the default objective for experiments and the headline funnel metric.`,
|
|
4249
|
+
schema: z.object({
|
|
4250
|
+
name: z.string().describe('Human label, e.g. "Purchase" or "Signup"'),
|
|
4251
|
+
type: z
|
|
4252
|
+
.enum([
|
|
4253
|
+
"form_submit",
|
|
4254
|
+
"click",
|
|
4255
|
+
"pageview",
|
|
4256
|
+
"custom_event",
|
|
4257
|
+
"file_download",
|
|
4258
|
+
"engaged_time",
|
|
4259
|
+
])
|
|
4260
|
+
.describe("How the conversion is detected. custom_event is the most common (set eventName)."),
|
|
4261
|
+
eventName: z
|
|
4262
|
+
.string()
|
|
4263
|
+
.optional()
|
|
4264
|
+
.describe("For custom_event: the Apex Spec event name to match, e.g. 'purchase'."),
|
|
4265
|
+
selector: z
|
|
4266
|
+
.string()
|
|
4267
|
+
.optional()
|
|
4268
|
+
.describe("For click/form_submit: a CSS selector to match."),
|
|
4269
|
+
targetUrl: z
|
|
4270
|
+
.string()
|
|
4271
|
+
.optional()
|
|
4272
|
+
.describe("For pageview: the URL (or path) that counts as a completion."),
|
|
4273
|
+
isPrimary: z
|
|
4274
|
+
.boolean()
|
|
4275
|
+
.optional()
|
|
4276
|
+
.describe("Make this the primary conversion (only one can be primary)."),
|
|
4277
|
+
}),
|
|
4278
|
+
handler: async (args) => {
|
|
4279
|
+
const payload = {
|
|
4280
|
+
name: args.name,
|
|
4281
|
+
type: args.type,
|
|
4282
|
+
metricType: "binary",
|
|
4283
|
+
isPrimary: args.isPrimary ?? false,
|
|
4284
|
+
...(args.eventName ? { eventName: args.eventName } : {}),
|
|
4285
|
+
...(args.selector ? { selector: args.selector } : {}),
|
|
4286
|
+
...(args.targetUrl ? { targetUrl: args.targetUrl } : {}),
|
|
4287
|
+
};
|
|
4288
|
+
const data = await apiPost("/api/conversion-goals", payload);
|
|
4289
|
+
return {
|
|
4290
|
+
content: [
|
|
4291
|
+
{
|
|
4292
|
+
type: "text",
|
|
4293
|
+
text: `Conversion created.\n\n${JSON.stringify(data, null, 2)}`,
|
|
4294
|
+
},
|
|
4295
|
+
],
|
|
4296
|
+
};
|
|
4297
|
+
},
|
|
4298
|
+
},
|
|
4299
|
+
set_primary_conversion: {
|
|
4300
|
+
description: `${APEX} — Mark a Conversion as the primary one. Experiments without an explicit objective default to the primary conversion, and the headline funnel + scoring use it. Pass the conversion id from list_conversions.`,
|
|
4301
|
+
schema: z.object({
|
|
4302
|
+
conversionId: z.string().describe("The conversion id to promote (from list_conversions)."),
|
|
4303
|
+
}),
|
|
4304
|
+
handler: async ({ conversionId }) => {
|
|
4305
|
+
const data = await apiPatch("/api/conversion-goals", { id: conversionId, isPrimary: true });
|
|
4306
|
+
return {
|
|
4307
|
+
content: [
|
|
4308
|
+
{
|
|
4309
|
+
type: "text",
|
|
4310
|
+
text: `Primary conversion set.\n\n${JSON.stringify(data, null, 2)}`,
|
|
4311
|
+
},
|
|
4312
|
+
],
|
|
4313
|
+
};
|
|
4314
|
+
},
|
|
4315
|
+
},
|
|
4316
|
+
create_target: {
|
|
4317
|
+
description: `${APEX} — Create a Target: an aggregate KPI number to hit by a date (MRR, pipeline, "1,000 signups by Q3"). Optionally bind it to a Conversion (sourceConversionId from list_conversions) to auto-count progress from that conversion's completions; otherwise it's a manual number you update yourself.`,
|
|
4318
|
+
schema: z.object({
|
|
4319
|
+
metric: z.string().describe('What you\'re tracking, e.g. "MRR", "Signups", "Pipeline".'),
|
|
4320
|
+
target: z.number().describe("The numeric goal to reach."),
|
|
4321
|
+
deadline: z.string().describe('Target date, ISO "YYYY-MM-DD".'),
|
|
4322
|
+
sourceConversionId: z
|
|
4323
|
+
.string()
|
|
4324
|
+
.optional()
|
|
4325
|
+
.describe("Optional: bind to a Conversion (from list_conversions) to auto-track progress."),
|
|
4326
|
+
}),
|
|
4327
|
+
handler: async (args) => {
|
|
4328
|
+
const payload = {
|
|
4329
|
+
metric: args.metric,
|
|
4330
|
+
target: args.target,
|
|
4331
|
+
deadline: args.deadline,
|
|
4332
|
+
...(args.sourceConversionId
|
|
4333
|
+
? { sourceConversionId: args.sourceConversionId }
|
|
4334
|
+
: {}),
|
|
4335
|
+
};
|
|
4336
|
+
const data = await apiPost("/api/targets", payload);
|
|
4337
|
+
return {
|
|
4338
|
+
content: [
|
|
4339
|
+
{
|
|
4340
|
+
type: "text",
|
|
4341
|
+
text: `Target created.\n\n${JSON.stringify(data, null, 2)}`,
|
|
4342
|
+
},
|
|
4343
|
+
],
|
|
4344
|
+
};
|
|
4345
|
+
},
|
|
4346
|
+
},
|
|
3789
4347
|
};
|
|
3790
4348
|
//# sourceMappingURL=tools.js.map
|