@apex-inc/mcp-server 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +19 -18
  2. package/dist/api-client.d.ts +5 -0
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/api-client.js +40 -24
  5. package/dist/api-client.js.map +1 -1
  6. package/dist/conflict-advisory.d.ts +29 -0
  7. package/dist/conflict-advisory.d.ts.map +1 -0
  8. package/dist/conflict-advisory.js +48 -0
  9. package/dist/conflict-advisory.js.map +1 -0
  10. package/dist/index.js +8 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/live-test-guard.d.ts +1 -0
  13. package/dist/live-test-guard.d.ts.map +1 -1
  14. package/dist/live-test-guard.js +4 -3
  15. package/dist/live-test-guard.js.map +1 -1
  16. package/dist/prompts.d.ts.map +1 -1
  17. package/dist/prompts.js +5 -7
  18. package/dist/prompts.js.map +1 -1
  19. package/dist/setup-catchup-tools.d.ts.map +1 -1
  20. package/dist/setup-catchup-tools.js +15 -3
  21. package/dist/setup-catchup-tools.js.map +1 -1
  22. package/dist/smoke-tools.d.ts +56 -0
  23. package/dist/smoke-tools.d.ts.map +1 -0
  24. package/dist/smoke-tools.js +261 -0
  25. package/dist/smoke-tools.js.map +1 -0
  26. package/dist/tool-catalog.d.ts +10 -0
  27. package/dist/tool-catalog.d.ts.map +1 -0
  28. package/dist/tool-catalog.js +36 -0
  29. package/dist/tool-catalog.js.map +1 -0
  30. package/dist/tool-groups.d.ts +5 -0
  31. package/dist/tool-groups.d.ts.map +1 -0
  32. package/dist/tool-groups.js +206 -0
  33. package/dist/tool-groups.js.map +1 -0
  34. package/dist/tool-shared.d.ts +300 -0
  35. package/dist/tool-shared.d.ts.map +1 -0
  36. package/dist/tool-shared.js +319 -0
  37. package/dist/tool-shared.js.map +1 -0
  38. package/dist/tools/accounts.d.ts +124 -0
  39. package/dist/tools/accounts.d.ts.map +1 -0
  40. package/dist/tools/accounts.js +99 -0
  41. package/dist/tools/accounts.js.map +1 -0
  42. package/dist/tools/adoption.d.ts +499 -0
  43. package/dist/tools/adoption.d.ts.map +1 -0
  44. package/dist/tools/adoption.js +447 -0
  45. package/dist/tools/adoption.js.map +1 -0
  46. package/dist/tools/context.d.ts +216 -0
  47. package/dist/tools/context.d.ts.map +1 -0
  48. package/dist/tools/context.js +328 -0
  49. package/dist/tools/context.js.map +1 -0
  50. package/dist/tools/experiments.d.ts +1066 -0
  51. package/dist/tools/experiments.d.ts.map +1 -0
  52. package/dist/tools/experiments.js +2090 -0
  53. package/dist/tools/experiments.js.map +1 -0
  54. package/dist/tools/letters.d.ts +1107 -0
  55. package/dist/tools/letters.d.ts.map +1 -0
  56. package/dist/tools/letters.js +1324 -0
  57. package/dist/tools/letters.js.map +1 -0
  58. package/dist/tools/measurement.d.ts +886 -0
  59. package/dist/tools/measurement.d.ts.map +1 -0
  60. package/dist/tools/measurement.js +1353 -0
  61. package/dist/tools/measurement.js.map +1 -0
  62. package/dist/tools/partners.d.ts +364 -0
  63. package/dist/tools/partners.d.ts.map +1 -0
  64. package/dist/tools/partners.js +199 -0
  65. package/dist/tools/partners.js.map +1 -0
  66. package/dist/tools.d.ts +3161 -2826
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +26 -6133
  69. package/dist/tools.js.map +1 -1
  70. package/package.json +3 -2
  71. package/skills/apex-communications/SKILL.md +5 -5
  72. package/skills/apex-experimentation/SKILL.md +11 -14
  73. package/skills/apex-growth-intelligence/SKILL.md +1 -1
  74. package/skills/apex-integration-cookbook/SKILL.md +1 -1
@@ -0,0 +1,1353 @@
1
+ import { z } from "zod";
2
+ import { apiGet, apiPost, apiPatch, apiPut, postWithIdempotency, getActiveWorkspace, } from "../api-client.js";
3
+ import { getPersonLabel, lowerPerson } from "../person-label.js";
4
+ import { APEX, MCP_AGENT_VISITOR_ID, windowQuery, } from "../tool-shared.js";
5
+ export const measurementTools = {
6
+ list_events: {
7
+ description: `${APEX} — List the workspace's ACTIVE events: the canonical event names actually firing, with total volume and the platforms they arrive on, from the ingest catalog. Use this to discover which events exist / are wired before choosing a primary metric or guardrail in create_experiment, or to confirm an event is live. Distinct from list_metrics (which lists typed rate/revenue/duration GOALS) — this is the raw observed-event catalog.`,
8
+ schema: z.object({
9
+ limit: z
10
+ .number()
11
+ .int()
12
+ .min(1)
13
+ .max(200)
14
+ .optional()
15
+ .describe("Max events to return, by volume descending (default 50)."),
16
+ }),
17
+ handler: async (args) => {
18
+ const catalog = await apiGet("/api/events/catalog");
19
+ const rows = catalog?.events ?? [];
20
+ // Aggregate per event name across platforms (the catalog is keyed by
21
+ // (eventName, platform); the agent wants one row per event).
22
+ const byName = new Map();
23
+ for (const r of rows) {
24
+ const e = byName.get(r.eventName) ?? { count: 0, platforms: new Set() };
25
+ e.count += r.count ?? 0;
26
+ if (r.platform)
27
+ e.platforms.add(r.platform);
28
+ byName.set(r.eventName, e);
29
+ }
30
+ const events = [...byName.entries()]
31
+ .map(([name, v]) => ({
32
+ event: name,
33
+ count: v.count,
34
+ platforms: [...v.platforms].sort(),
35
+ }))
36
+ .sort((a, b) => b.count - a.count)
37
+ .slice(0, args.limit ?? 50);
38
+ if (events.length === 0) {
39
+ return {
40
+ content: [
41
+ {
42
+ type: "text",
43
+ text: `${APEX} No active events yet — nothing has been tracked in this workspace. Install the snippet / SDK and fire events, then re-check.`,
44
+ },
45
+ ],
46
+ };
47
+ }
48
+ const lines = events.map((e) => ` • ${e.event} — ${e.count.toLocaleString()} events (${e.platforms.join(", ") || "web"})`);
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: [
54
+ `${APEX} Active events (${events.length}, by volume):`,
55
+ ...lines,
56
+ ``,
57
+ `Pick a measurable OUTCOME event (e.g. purchase/signup) as primaryMetricEvent in create_experiment. Use list_metrics to see which are typed goals.`,
58
+ ].join("\n"),
59
+ },
60
+ ],
61
+ };
62
+ },
63
+ },
64
+ list_metrics: {
65
+ description: `${APEX} — List the canonical metric/goal catalog for this workspace: typed metrics (rate/revenue/duration) with whether each is DETECTED (firing, with volume) and its outcome tier. Use this to pick a measurable OUTCOME goal (purchase/signup) over a proxy (email_open) before create_experiment, or role:'guardrail' to see protective harm signals.`,
66
+ schema: z.object({
67
+ onlyMeasurable: z
68
+ .boolean()
69
+ .optional()
70
+ .describe("Only return metrics the engine can estimate today (default true)."),
71
+ role: z
72
+ .enum(["goal", "guardrail"])
73
+ .optional()
74
+ .describe("Filter to goal metrics (optimize) or guardrail metrics (protect against harm)."),
75
+ }),
76
+ handler: async ({ onlyMeasurable, role }) => {
77
+ const res = await apiGet(role ? `/api/metrics/catalog?role=${role}` : "/api/metrics/catalog");
78
+ const all = res.data ?? [];
79
+ const list = (onlyMeasurable === false ? all : all.filter((m) => m.measurable)).slice(0, 60);
80
+ if (list.length === 0) {
81
+ return {
82
+ content: [{ type: "text", text: `${APEX} No metrics in the catalog yet.` }],
83
+ };
84
+ }
85
+ const lines = list
86
+ .map((m) => {
87
+ const tags = [
88
+ m.type,
89
+ m.outcomeTier === "proximal" ? "proxy" : m.outcomeTier,
90
+ m.wired === "detected"
91
+ ? `DETECTED${m.trailingVolume ? ` (${m.trailingVolume})` : ""}`
92
+ : "not firing",
93
+ m.measurable ? null : "not measurable",
94
+ ]
95
+ .filter(Boolean)
96
+ .join(", ");
97
+ return ` • ${m.key} — ${m.label} (${tags})`;
98
+ })
99
+ .join("\n");
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: [
105
+ `${APEX} Metric catalog (${list.length})`,
106
+ `${"═".repeat(40)}`,
107
+ lines,
108
+ ``,
109
+ `Prefer a DETECTED outcome metric. Pass its event to create_experiment as primaryMetricEvent (+ primaryMetricType 'revenue' for value goals).`,
110
+ ].join("\n"),
111
+ },
112
+ ],
113
+ };
114
+ },
115
+ },
116
+ get_growth_reality: {
117
+ 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.`,
118
+ schema: z.object({
119
+ days: z
120
+ .union([z.number(), z.literal("all")])
121
+ .optional()
122
+ .describe("Trailing window in days, or 'all' for since-inception (default)."),
123
+ }),
124
+ handler: async ({ days }) => {
125
+ const q = days && days !== "all" ? `?days=${days}` : "?days=all";
126
+ const res = await apiGet(`/api/growth/reality${q}`);
127
+ const goals = res.data?.goals ?? [];
128
+ if (goals.length === 0) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text",
133
+ text: `${APEX} No growth-reality data yet. Counting is forward-only from when the global holdout went live — check back once exposed + holdout cohorts are flowing.`,
134
+ },
135
+ ],
136
+ };
137
+ }
138
+ const pctFmt = (v) => v == null ? "—" : `${v > 0 ? "+" : ""}${(v * 100).toFixed(1)}%`;
139
+ const lines = goals.map((g) => {
140
+ if (!g.isDecisive) {
141
+ return ` • ${g.goalName}: COLLECTING (directional ${pctFmt(g.lift)}, since ${g.collectingSince || "—"})`;
142
+ }
143
+ const ci = g.liftCI
144
+ ? ` [${pctFmt(g.liftCI.lower)}, ${pctFmt(g.liftCI.upper)}]`
145
+ : "";
146
+ const rev = typeof g.incrementalRevenue === "number"
147
+ ? ` · ~$${Math.round(g.incrementalRevenue).toLocaleString()} incremental`
148
+ : "";
149
+ return ` • ${g.goalName}: ${pctFmt(g.lift)} vs holdout${ci} — DECISIVE${rev}`;
150
+ });
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: [
156
+ `${APEX} Is our growth real? (vs ${res.data?.holdoutPct ?? 5}% do-nothing holdout)`,
157
+ `${"═".repeat(40)}`,
158
+ ...lines,
159
+ ``,
160
+ `Decisive = both cohorts cleared the minimum size AND the CI excludes zero. Everything else is directional, not proven.`,
161
+ ].join("\n"),
162
+ },
163
+ ],
164
+ };
165
+ },
166
+ },
167
+ get_channel_economics: {
168
+ description: `${APEX} — "Where should my next dollar go?" Per acquisition channel (raw first-touch): LTGP:CAC (lifetime gross profit over CAC) where the merchant reports product cost, LTV:CAC (lifetime revenue over CAC) everywhere else, plus 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.`,
169
+ schema: z.object({
170
+ days: z
171
+ .union([z.number(), z.literal("all")])
172
+ .optional()
173
+ .describe("Trailing window in days, or 'all' for since-inception (default)."),
174
+ }),
175
+ handler: async ({ days }) => {
176
+ const q = days && days !== "all" ? `?days=${days}` : "?days=all";
177
+ const res = await apiGet(`/api/channels/economics${q}`);
178
+ const channels = res.data?.channels ?? [];
179
+ if (channels.length === 0) {
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text",
184
+ text: `${APEX} No channel economics yet — connect attributed traffic (Apex Links auto-tag it) and revenue so acquisition channels can be measured.`,
185
+ },
186
+ ],
187
+ };
188
+ }
189
+ const label = (slug) => ({
190
+ linkedin: "LinkedIn Ads",
191
+ google: "Google",
192
+ meta: "Meta",
193
+ microsoft: "Microsoft Ads",
194
+ tiktok: "TikTok",
195
+ twitter: "Twitter / X",
196
+ direct: "Direct",
197
+ email: "Email",
198
+ affiliate: "Partner Network",
199
+ })[slug] ?? slug.charAt(0).toUpperCase() + slug.slice(1);
200
+ const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
201
+ const ratio = (r) => (r == null ? "—" : `${r.toFixed(2)}x`);
202
+ // Each channel is ranked on its best available multiple. Both are real
203
+ // metrics — LTGP:CAC nets out cost of goods, LTV:CAC doesn't — so the
204
+ // line names which one it printed rather than dropping the number.
205
+ const best = (c) => c.ltgpCac ?? c.ltvCac;
206
+ const metric = (c) => c.ltgpCac !== null ? "LTGP:CAC" : "LTV:CAC";
207
+ const ranked = [...channels].sort((a, b) => (best(b) ?? -1) - (best(a) ?? -1));
208
+ const acquisition = ranked.filter((c) => c.spend !== null && c.spend > 0);
209
+ const leverage = ranked.filter((c) => c.spend === null || c.spend === 0);
210
+ // The merchant reads this back on a screen that says "Leads" or
211
+ // "Clients" if that's their vertical — say the same word they do.
212
+ const person = lowerPerson(await getPersonLabel());
213
+ const fmt = (c) => ` • ${label(c.channel)}: ${ratio(best(c))} ${metric(c)} · payback ${c.paybackMonths != null ? `${c.paybackMonths}mo` : "—"} · CAC ${money(c.cac)} · ${c.customers.toLocaleString()} ${person.plural}`;
214
+ const lines = [
215
+ `${APEX} Channel economics — LTGP:CAC where product cost is reported, LTV:CAC otherwise`,
216
+ "═".repeat(40),
217
+ ];
218
+ if (res.data?.needsReferee) {
219
+ lines.push("Revenue figures hidden: two live revenue sources with no referee picked. Choose one in Connect revenue.", "");
220
+ }
221
+ if (acquisition.length > 0) {
222
+ lines.push("Acquisition (paid):", ...acquisition.map(fmt), "");
223
+ }
224
+ if (leverage.length > 0) {
225
+ lines.push("Leverage (owned / organic):", ...leverage.map(fmt), "");
226
+ }
227
+ lines.push(`3-5x is healthy; <1x loses money on every ${person.label}; >5x is likely underinvested.`);
228
+ if (ranked.some((c) => c.ltgpCac === null && c.ltvCac !== null)) {
229
+ lines.push(`(Channels above marked LTV:CAC are measured on revenue. Send product.unit_cost on every purchased item and Apex reports LTGP:CAC for them too — the same read with cost of goods netted out. unit_cost is PER UNIT and gets multiplied by quantity; if you only know the order's total cost, send cost at the order level instead. Cost is all-or-nothing per order: an order with an uncosted line contributes to LTV:CAC only, so a partial rollout never inflates gross profit.)`);
230
+ }
231
+ return { content: [{ type: "text", text: lines.join("\n") }] };
232
+ },
233
+ },
234
+ // ─── Vertical Widget Packs read tools (2026-07-02) ─────────────────────
235
+ // One tool per pack read API. All aggregate-only (analytics:read scope);
236
+ // small-n suppression applies to API responses exactly as to widgets.,
237
+ get_saas_recurring_revenue: {
238
+ 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.`,
239
+ schema: z.object({
240
+ months: z
241
+ .number()
242
+ .optional()
243
+ .describe("Months of waterfall history to include (2-36, default 13)."),
244
+ start: z
245
+ .string()
246
+ .optional()
247
+ .describe("Explicit window start, YYYY-MM-DD (mapped to calendar months; use with end — overrides months)."),
248
+ end: z
249
+ .string()
250
+ .optional()
251
+ .describe("Explicit window end, YYYY-MM-DD (use with start)."),
252
+ }),
253
+ handler: async ({ months, start, end }) => {
254
+ const q = windowQuery({ months, start, end });
255
+ const res = await apiGet(`/api/saas/recurring-revenue${q}`);
256
+ const d = res.data;
257
+ if (!d?.hasData) {
258
+ return {
259
+ content: [
260
+ {
261
+ type: "text",
262
+ 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.`,
263
+ },
264
+ ],
265
+ };
266
+ }
267
+ const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
268
+ const pct = (r) => r == null ? "collecting history" : `${Math.round(r * 1000) / 10}%`;
269
+ const lines = [
270
+ `${APEX} Recurring revenue`,
271
+ "═".repeat(40),
272
+ `MRR ${money(d.currentMrr)} (month in progress) · ARR ${money(d.currentArr)} · ${d.payingAccounts ?? "—"} paying accounts`,
273
+ `NRR ${pct(d.retention?.nrr ?? null)} · GRR ${pct(d.retention?.grr ?? null)} (trailing 12mo, ${d.retention?.cohortSize ?? 0}-account cohort)`,
274
+ "",
275
+ "Waterfall (last months):",
276
+ ...d.waterfall
277
+ .slice(-6)
278
+ .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)`),
279
+ ];
280
+ return { content: [{ type: "text", text: lines.join("\n") }] };
281
+ },
282
+ },
283
+ get_saas_expansion: {
284
+ 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.`,
285
+ schema: z.object({
286
+ months: z
287
+ .number()
288
+ .optional()
289
+ .describe("Months of series history to include (2-36, default 13)."),
290
+ start: z
291
+ .string()
292
+ .optional()
293
+ .describe("Explicit window start, YYYY-MM-DD (mapped to calendar months; use with end — overrides months)."),
294
+ end: z
295
+ .string()
296
+ .optional()
297
+ .describe("Explicit window end, YYYY-MM-DD (use with start)."),
298
+ }),
299
+ handler: async ({ months, start, end }) => {
300
+ const q = windowQuery({ months, start, end });
301
+ const res = await apiGet(`/api/saas/expansion${q}`);
302
+ const d = res.data;
303
+ if (!d?.hasData) {
304
+ return {
305
+ content: [
306
+ {
307
+ type: "text",
308
+ text: `${APEX} No subscription data yet — expansion metrics unlock once MRR snapshots exist.`,
309
+ },
310
+ ],
311
+ };
312
+ }
313
+ const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
314
+ const seatsLine = d.seatCount != null
315
+ ? `Seats: ${d.seatCount}${d.seatCountPrevMonth != null ? ` (${d.seatCount - d.seatCountPrevMonth >= 0 ? "+" : ""}${d.seatCount - d.seatCountPrevMonth} vs last month)` : ""}`
316
+ : "Seats: — (send seat_count on subscription events to unlock)";
317
+ return {
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: [
322
+ `${APEX} Expansion (last complete month)`,
323
+ "═".repeat(40),
324
+ `Expansion MRR ${money(d.expansionMrrLastMonth)} · Contraction MRR ${money(d.contractionMrrLastMonth)}`,
325
+ `Accounts expanding: ${d.expandedAccounts ?? "—"} of ${d.payingAccountsAtStart ?? "—"} paying at month start`,
326
+ seatsLine,
327
+ ].join("\n"),
328
+ },
329
+ ],
330
+ };
331
+ },
332
+ },
333
+ get_ecommerce_product_sales: {
334
+ description: `${APEX} — E-commerce product sales: 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.`,
335
+ schema: z.object({
336
+ days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
337
+ start: z
338
+ .string()
339
+ .optional()
340
+ .describe("Explicit window start, YYYY-MM-DD (use with end — overrides days)."),
341
+ end: z
342
+ .string()
343
+ .optional()
344
+ .describe("Explicit window end, YYYY-MM-DD (use with start)."),
345
+ }),
346
+ handler: async ({ days, start, end }) => {
347
+ const q = windowQuery({ days, start, end });
348
+ const res = await apiGet(`/api/ecommerce/product-sales${q}`);
349
+ const d = res.data;
350
+ if (!d?.hasData) {
351
+ return {
352
+ content: [
353
+ {
354
+ type: "text",
355
+ text: `${APEX} No product-scoped events yet — fire product_view / add_to_cart / purchases with a product_id.`,
356
+ },
357
+ ],
358
+ };
359
+ }
360
+ const money = (n) => `$${Math.round(n).toLocaleString()}`;
361
+ const lines = [
362
+ `${APEX} Product sales — ${money(d.totals.revenue)} product revenue, ${d.totals.purchases} purchases`,
363
+ "═".repeat(40),
364
+ ...d.items
365
+ .slice(0, 10)
366
+ .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` : ""}`),
367
+ ];
368
+ if (!d.wired.views)
369
+ lines.push("(product_view not wired — funnel rates unavailable)");
370
+ return { content: [{ type: "text", text: lines.join("\n") }] };
371
+ },
372
+ },
373
+ list_product_costs: {
374
+ description: `${APEX} — What each product costs, and which ones Apex has no cost for. Lists every SKU with a declared cost PLUS every SKU the merchant's orders have sold in the last 30 days, so an uncosted product is visible rather than merely absent. Apex reports gross profit and LTGP:CAC only where cost is known, so the uncosted rows are the gap worth closing. Two columns matter: "orders" is the per-unit cost the merchant's own purchase events reported (preferred — it tracks their real costs over time), "declared" is a standing cost set in Apex that fills orders reporting none. Use this before set_product_cost so you never overwrite a cost that is already right.`,
375
+ schema: z.object({
376
+ uncostedOnly: z
377
+ .boolean()
378
+ .optional()
379
+ .describe("Only SKUs with no cost from either route — the actionable list."),
380
+ limit: z.number().optional().describe("Max rows to show (default 25)."),
381
+ }),
382
+ handler: async ({ uncostedOnly, limit, }) => {
383
+ const res = await apiGet("/api/products");
384
+ const all = res.data?.products ?? [];
385
+ if (all.length === 0) {
386
+ return {
387
+ content: [
388
+ {
389
+ type: "text",
390
+ // Not "no products" — an empty list here means Apex has neither a
391
+ // declared cost NOR an order it could name a product from, and
392
+ // those are different problems with different fixes.
393
+ text: `${APEX} No products on file. Apex lists a product once it either has a declared cost or appears on an order — so send purchase events with product_id on each line item (see get_apex_spec for "order_placed"), or connect Shopify to import variant costs.`,
394
+ },
395
+ ],
396
+ };
397
+ }
398
+ const costKnown = (p) => p.costUsd !== undefined || p.unitCostUsd !== undefined;
399
+ const rows = uncostedOnly ? all.filter((p) => !costKnown(p)) : all;
400
+ const money = (n) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
401
+ const withCost = all.filter(costKnown).length;
402
+ const lines = [
403
+ `${APEX} Product cost — Apex knows the cost of ${withCost} of ${all.length} products`,
404
+ "═".repeat(40),
405
+ ...rows.slice(0, limit ?? 25).map((p) => {
406
+ const observed = p.unitCostUsd !== undefined
407
+ ? `orders ${money(p.unitCostUsd)}`
408
+ : "orders: not reported";
409
+ const declared = p.costUsd !== undefined
410
+ ? `declared ${money(p.costUsd)}${p.costSource ? ` (${p.costSource})` : ""}${p.costEffectiveFrom ? ` from ${p.costEffectiveFrom.slice(0, 10)}` : ""}`
411
+ : "declared: none";
412
+ const sold = p.revenue !== undefined && p.revenue > 0
413
+ ? ` · ${money(p.revenue)} in 30d`
414
+ : "";
415
+ return ` • ${p.name ?? p.itemKey} [${p.itemKey}]: ${observed} · ${declared}${sold}`;
416
+ }),
417
+ ];
418
+ if (rows.length > (limit ?? 25)) {
419
+ lines.push(` … and ${rows.length - (limit ?? 25)} more`);
420
+ }
421
+ const uncosted = all.filter((p) => !costKnown(p)).length;
422
+ if (uncosted > 0) {
423
+ lines.push("", `${uncosted} product${uncosted === 1 ? "" : "s"} have no cost. The durable fix is sending unit_cost (per unit) on each purchase line item, which keeps up as costs change. Use set_product_cost to declare a standing cost where the checkout can't report one, or to backfill history.`);
424
+ }
425
+ if (res.metadata?.observed?.truncated) {
426
+ lines.push("", "(The 30-day sales scan hit its read cap, so this list is a lower bound — a SKU that sold only in the unread remainder is missing.)");
427
+ }
428
+ return { content: [{ type: "text", text: lines.join("\n") }] };
429
+ },
430
+ },
431
+ set_product_cost: {
432
+ description: `${APEX} — Declare what one product costs per unit, so Apex can report gross profit on orders that didn't carry a cost of their own. PREFER instrumenting unit_cost on purchase line items instead where you can: an order carries the cost that was true when it happened, whereas a standing cost declared here is a snapshot that goes stale silently. Use this to backfill history, or for a SKU whose checkout can't report cost. NEVER invent or estimate a cost — a fabricated margin looks measured and is not, and the merchant has no way to tell downstream. Ask them for the number. Rules: the cost is PER UNIT (not a line total), it never overrides a cost an order reported for itself, and it never applies backward before effective_from.`,
433
+ schema: z.object({
434
+ item_key: z
435
+ .string()
436
+ .describe("The product identifier Apex knows this SKU by — the product_id or sku on the merchant's order lines. Get it from list_product_costs."),
437
+ cost_usd: z
438
+ .number()
439
+ .describe("Cost PER UNIT in USD. A four-figure value on a low-priced item is usually cents typed as dollars; values above 1000000 are refused."),
440
+ effective_from: z
441
+ .string()
442
+ .optional()
443
+ .describe("ISO date the cost applies from. Defaults to NOW, which is deliberate — defaulting backward would restate the merchant's whole order history at today's supplier price. Pass an explicit date only when they know the older price."),
444
+ name: z
445
+ .string()
446
+ .optional()
447
+ .describe("Display name, if Apex has no name for this SKU yet."),
448
+ }),
449
+ handler: async ({ item_key, cost_usd, effective_from, name, }) => {
450
+ const res = await apiPut(`/api/products/${encodeURIComponent(item_key)}/cost`, {
451
+ cost_usd,
452
+ ...(effective_from ? { effective_from } : {}),
453
+ ...(name ? { name } : {}),
454
+ });
455
+ const d = res.data;
456
+ if (d && !d.changed) {
457
+ return {
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: `${APEX} ${item_key} already had that cost on file — nothing changed, no log entry written.`,
462
+ },
463
+ ],
464
+ };
465
+ }
466
+ const from = d?.entry?.effectiveFrom?.slice(0, 10) ?? "now";
467
+ return {
468
+ content: [
469
+ {
470
+ type: "text",
471
+ text: `${APEX} Set ${item_key} to $${cost_usd.toFixed(2)} per unit, applying from ${from} forward. Orders before that date, and orders that reported their own cost, are untouched. Check list_product_costs to see what's still uncosted.`,
472
+ },
473
+ ],
474
+ };
475
+ },
476
+ },
477
+ upload_product_catalog: {
478
+ description: `${APEX} — Tell Apex what the merchant sells, in bulk, BEFORE the first sale. Every other way a product reaches Apex needs a sale to have happened already, so a merchant setting up pre-launch has a catalog Apex knows nothing about. Use this when you can read their catalog from somewhere — a products table, a Shopify/Stripe export, a CSV they hand you. NEVER invent, estimate, or derive a cost from a price: a fabricated margin looks measured and nobody downstream can tell. Ask them, or send only the fields you actually know. cost_usd is applied to orders that report no cost of their own, from effective_from forward. price_usd is reference only — revenue, LTV and gross profit always come from what orders actually report, so a price here can never distort them; it makes the catalog complete before launch and lets Apex flag it when real orders disagree with it. Re-running with the same data is safe. Max 500 products per call — page through a larger catalog. This does not replace instrumenting unit_cost on purchase line items, which stays the durable fix because an order then carries the cost that was true when it happened; a catalog cost is a snapshot that goes stale silently.`,
479
+ schema: z.object({
480
+ products: z
481
+ .array(z.object({
482
+ item_key: z
483
+ .string()
484
+ .describe("The identifier the merchant's ORDERS will send for this SKU — their product_id or sku. If this doesn't match what the order lines carry, Apex can't connect the two."),
485
+ name: z.string().optional().describe("Display name."),
486
+ cost_usd: z
487
+ .number()
488
+ .optional()
489
+ .describe("What the product costs the MERCHANT, per unit. Omit rather than guess."),
490
+ price_usd: z
491
+ .number()
492
+ .optional()
493
+ .describe("What the merchant SELLS it for, per unit. Reference data — never used as revenue."),
494
+ effective_from: z
495
+ .string()
496
+ .optional()
497
+ .describe("ISO date cost_usd applies from. Defaults to now, which is deliberate — defaulting backward would restate order history at today's supplier price."),
498
+ }))
499
+ .describe("Up to 500 products. Each needs item_key plus at least one of name, cost_usd, price_usd."),
500
+ }),
501
+ handler: async ({ products, }) => {
502
+ const res = await apiPost("/api/products", { products });
503
+ const d = res.data;
504
+ if (!d) {
505
+ return {
506
+ content: [
507
+ { type: "text", text: `${APEX} Catalog upload returned no result.` },
508
+ ],
509
+ };
510
+ }
511
+ const parts = [
512
+ `${APEX} Saved ${d.upserted} product${d.upserted === 1 ? "" : "s"} — ${d.costChanged} with a new cost, ${d.priceSet} with a price.`,
513
+ ];
514
+ if (d.rejected > 0) {
515
+ // Named rather than counted: a rejected row is a line the merchant can
516
+ // fix, and "12 rejected" sends them back through the whole file.
517
+ const listed = (d.errors ?? [])
518
+ .slice(0, 10)
519
+ .map((e) => ` - ${e.item_key || "(no item_key)"}: ${e.error}`)
520
+ .join("\n");
521
+ parts.push(`${d.rejected} row${d.rejected === 1 ? "" : "s"} rejected:\n${listed}`);
522
+ if ((d.errors?.length ?? 0) > 10) {
523
+ parts.push(`…and ${(d.errors?.length ?? 0) - 10} more.`);
524
+ }
525
+ }
526
+ parts.push(`Costs apply only to orders that report no cost of their own. The durable fix is still sending unit_cost on each purchase line item — run list_product_costs once orders start arriving to see what's still uncovered.`);
527
+ return { content: [{ type: "text", text: parts.join("\n\n") }] };
528
+ },
529
+ },
530
+ get_ecommerce_returns: {
531
+ description: `${APEX} — E-commerce 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.`,
532
+ schema: z.object({
533
+ days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
534
+ start: z
535
+ .string()
536
+ .optional()
537
+ .describe("Explicit window start, YYYY-MM-DD (use with end — overrides days)."),
538
+ end: z
539
+ .string()
540
+ .optional()
541
+ .describe("Explicit window end, YYYY-MM-DD (use with start)."),
542
+ }),
543
+ handler: async ({ days, start, end }) => {
544
+ const q = windowQuery({ days, start, end });
545
+ const res = await apiGet(`/api/ecommerce/returns${q}`);
546
+ const d = res.data;
547
+ if (!d?.hasData) {
548
+ return {
549
+ content: [
550
+ {
551
+ type: "text",
552
+ text: `${APEX} No order data in the window yet — connect Stripe or send purchase events.`,
553
+ },
554
+ ],
555
+ };
556
+ }
557
+ const lines = [
558
+ `${APEX} Returns & refunds`,
559
+ "═".repeat(40),
560
+ `Refunded orders: ${d.refundedOrders} of ${d.ordersInWindow} ($${Math.round(d.refundedAmount).toLocaleString()})`,
561
+ `Returns (goods back): ${d.returnsCompleted ?? "— (wire return_completed to unlock)"}`,
562
+ `Refund cycle: ${d.refundCycleHours != null ? `${d.refundCycleHours}h median` : "—"} · AOV: ${d.aov != null ? `$${d.aov}` : "—"} · Repeat: ${d.repeatCustomers} of ${d.purchasingCustomers} customers`,
563
+ ];
564
+ if (d.reasons.length > 0) {
565
+ lines.push("", "Reasons:", ...d.reasons.map((r) => ` • ${r.reason}: ${r.count}`));
566
+ }
567
+ return { content: [{ type: "text", text: lines.join("\n") }] };
568
+ },
569
+ },
570
+ get_marketplace_metrics: {
571
+ 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.`,
572
+ schema: z.object({
573
+ days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
574
+ start: z
575
+ .string()
576
+ .optional()
577
+ .describe("Explicit window start, YYYY-MM-DD (use with end — overrides days)."),
578
+ end: z
579
+ .string()
580
+ .optional()
581
+ .describe("Explicit window end, YYYY-MM-DD (use with start)."),
582
+ category: z.string().optional().describe("Slice: listing category."),
583
+ geo: z.string().optional().describe("Slice: transaction geo."),
584
+ priceBand: z.string().optional().describe("Slice: price band (e.g. '100-250')."),
585
+ }),
586
+ handler: async ({ days, start, end, category, geo, priceBand, }) => {
587
+ const params = new URLSearchParams();
588
+ if (days)
589
+ params.set("days", String(days));
590
+ if (start && end) {
591
+ params.set("start", start);
592
+ params.set("end", end);
593
+ }
594
+ if (category)
595
+ params.set("category", category);
596
+ if (geo)
597
+ params.set("geo", geo);
598
+ if (priceBand)
599
+ params.set("price_band", priceBand);
600
+ const qs = params.toString();
601
+ const res = await apiGet(`/api/marketplace/metrics${qs ? `?${qs}` : ""}`);
602
+ const d = res.data;
603
+ if (!d?.hasData) {
604
+ return {
605
+ content: [
606
+ {
607
+ type: "text",
608
+ text: `${APEX} No marketplace events yet — wire seller_signup, listing_created, search, offer_submitted, and transaction_completed (with fee_amount, buyer_id, seller_id).`,
609
+ },
610
+ ],
611
+ };
612
+ }
613
+ if (d.sliceSuppressed) {
614
+ return {
615
+ content: [
616
+ {
617
+ type: "text",
618
+ text: `${APEX} That slice has too few participants to report honestly (privacy floor). Broaden the slice.`,
619
+ },
620
+ ],
621
+ };
622
+ }
623
+ const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
624
+ const L = d.liquidity;
625
+ const lines = [
626
+ `${APEX} Marketplace metrics`,
627
+ "═".repeat(40),
628
+ `Liquidity: ${L.searchersWhoTransacted}/${L.searchers} searchers transacted (7d)` +
629
+ (L.searchesWithResultCount > 0
630
+ ? ` · ${L.zeroResultSearches}/${L.searchesWithResultCount} zero-result searches`
631
+ : "") +
632
+ ` · ${L.transactions} transactions / ${L.offers} offers` +
633
+ (L.medianHoursToMatch != null ? ` · ${L.medianHoursToMatch}h median to match` : "") +
634
+ ` · ${L.buyersUnfulfilled}/${L.buyersWithIntent} buyers unfulfilled`,
635
+ `Supply & demand: ${d.supplyDemand.activeBuyers} buyers · ${d.supplyDemand.activeSellers} sellers (${d.supplyDemand.sellersWithTransaction} transacting) · ${d.supplyDemand.newListings} new listings`,
636
+ `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)}`,
637
+ d.demandMix
638
+ ? `Demand mix: paid ${money(d.demandMix.paidGmv)} · organic ${money(d.demandMix.organicGmv)} · unattributed ${money(d.demandMix.unattributedGmv)} (buyer first-touch)`
639
+ : "Demand mix: — (needs identified buyers with acquisition attribution)",
640
+ `Trust: ${d.trust.canceled} cancels · ${d.trust.refunded} refunds · ${d.trust.disputes} disputes over ${d.trust.transactions} transactions` +
641
+ (d.trust.averageRating != null ? ` · ${d.trust.averageRating}/5 avg rating` : ""),
642
+ d.concentration.suppressed
643
+ ? `Concentration: needs ≥5 sellers (currently ${d.concentration.sellerCount}) for an honest read`
644
+ : `Concentration: top 10% of sellers = ${Math.round((d.concentration.topSellerGmvShare?.top10 ?? 0) * 1000) / 10}% of GMV (${d.concentration.sellerCount} sellers)`,
645
+ ];
646
+ if (!d.wired.searches)
647
+ lines.push("(search not wired — liquidity is partial)");
648
+ return { content: [{ type: "text", text: lines.join("\n") }] };
649
+ },
650
+ },
651
+ track_event: {
652
+ description: `${APEX} — Track an event through the Apex Spec.
653
+
654
+ Identical to the SDK's track() and the snippet's apex.track(). Use this to instrument events from agent workflows, CI pipelines, or IDE actions.
655
+
656
+ The public Spec is one registry (\`APEX_EVENTS\`): \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`order_placed\`, \`in_app_purchase\`, \`user_signed_up\`, \`waitlist_joined\`, \`user_invited\`, \`user_invite_accepted\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`feature_used\`, \`ui_action\`. Field names are flat snake_case. Named in-product controls are \`ui_action\` with \`action\` + \`name\` + optional \`location\`. SDK helper: \`trackUiAction({ action, name, location })\`.
657
+
658
+ Custom event names are accepted, but prefer canonical names from GET /api/spec/events so templates, journeys, and conversions wire without aliases. Full spec: https://docs.apex.inc/spec.
659
+
660
+ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*, fixed for this MCP process) and a timestamp, so they WILL count toward wiring detection. They are flagged as agent-fired (source: "mcp") — not test-mode. Fire them deliberately.`,
661
+ schema: z.object({
662
+ event: z.string().describe("Event name — snake_case from the Apex Spec (e.g. 'page_view', 'user_signed_up', 'order_placed'). Custom names are accepted but prefer canonical ones."),
663
+ properties: z.record(z.unknown()).optional().describe("Flat snake_case properties matching the spec entry for this event (e.g. { product_id: 'prod_abc', value: 29.99, currency: 'USD' })."),
664
+ }),
665
+ handler: async ({ event, properties }) => {
666
+ // MOBX-006 — the batch path of /api/events reads `visitorId`,
667
+ // `type`, and the event bag (`data`) at the TOP LEVEL of each
668
+ // event. Without a visitorId the event lands in the raw SDK
669
+ // firehose but never reaches the tracking-event store that
670
+ // powers wiring detection / readiness (the route reports it
671
+ // under `untracked`). Stamp the per-process synthetic visitor
672
+ // id + timestamp so agent-fired events actually count.
673
+ const result = await apiPost("/api/events", {
674
+ workspaceKey: (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY) || "default",
675
+ userId: "mcp-agent",
676
+ events: [
677
+ {
678
+ type: event,
679
+ visitorId: MCP_AGENT_VISITOR_ID,
680
+ timestamp: new Date().toISOString(),
681
+ data: { ...properties, source: "mcp" },
682
+ },
683
+ ],
684
+ });
685
+ const wiringNote = result.untracked && result.untracked.length > 0
686
+ ? `\nNOT counted toward wiring detection: ${result.untracked[0].reason}`
687
+ : `\nCounts toward wiring detection (visitorId: ${MCP_AGENT_VISITOR_ID}, flagged agent-fired via source: "mcp").`;
688
+ return {
689
+ content: [{
690
+ type: "text",
691
+ text: `Event tracked: "${event}" — ${result.received} event(s) stored.${wiringNote}${properties ? `\nProperties: ${JSON.stringify(properties)}` : ""}`,
692
+ }],
693
+ };
694
+ },
695
+ },
696
+ send_server_event: {
697
+ description: `${APEX} — Server Events API. Send a server-to-server event via the /api/v1/events endpoint (clientType: "server", batch cap 100, idempotency-key supported). Use this from agent workflows that simulate backend integrations (Stripe webhook flows, CRM-driven conversions, billing events) or to test the Server Events API end-to-end. Differs from track_event in that it stamps clientType: "server" and uses the v1 ingest contract.`,
698
+ schema: z.object({
699
+ type: z.string().describe("Event type (e.g. 'order_placed', 'user_signed_up', or any custom verb)"),
700
+ visitorId: z.string().optional().describe("Visitor ID to stitch back to a web/mobile session (apex_vid)"),
701
+ email: z.string().optional().describe("Email to stitch by identity when no visitorId is known"),
702
+ data: z.record(z.unknown()).optional().describe("Event payload (value, currency, order_id, …)"),
703
+ environment: z
704
+ .enum(["production", "preview", "localhost"])
705
+ .optional()
706
+ .describe("Dashboard slice only. Omit to leave Unclassified. Never use this to force Production dollars."),
707
+ release_channel: z
708
+ .string()
709
+ .optional()
710
+ .describe("Optional OS channel (testflight, app-store, xcode-debug, …). Dashboard slice only."),
711
+ idempotencyKey: z.string().optional().describe("Stable key to prevent double-counting on retry (defaults to a fresh UUID)"),
712
+ }),
713
+ handler: async ({ type, visitorId, email, data, environment, release_channel, idempotencyKey, }) => {
714
+ const key = idempotencyKey ??
715
+ (typeof globalThis.crypto?.randomUUID === "function"
716
+ ? globalThis.crypto.randomUUID()
717
+ : `idk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`);
718
+ const result = await postWithIdempotency("/api/v1/events", key, {
719
+ events: [
720
+ {
721
+ type,
722
+ visitorId,
723
+ email,
724
+ data,
725
+ environment,
726
+ release_channel,
727
+ timestamp: new Date().toISOString(),
728
+ },
729
+ ],
730
+ });
731
+ const lines = [
732
+ `Server Events API event sent: "${type}"`,
733
+ ` received: ${result.data.received}, duplicates: ${result.data.duplicates}`,
734
+ ` idempotency-key: ${key}`,
735
+ ];
736
+ if (result.data.errors.length > 0) {
737
+ lines.push(` errors: ${JSON.stringify(result.data.errors)}`);
738
+ }
739
+ return { content: [{ type: "text", text: lines.join("\n") }] };
740
+ },
741
+ },
742
+ identify_user: {
743
+ 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. Pass avatarUrl (a canonical attribute) to give the Contact a profile photo that renders across Apex — the people list, detail page, and Live widget. The workspace's own word for a person (Customer / Lead / Client / User, from its vertical) is what its dashboard shows; use that word when reporting back.`,
744
+ schema: z.object({
745
+ email: z.string().describe("User email address"),
746
+ name: z.string().optional().describe("User name"),
747
+ company: z.string().optional().describe("Company name"),
748
+ 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)."),
749
+ metadata: z.record(z.unknown()).optional().describe("Additional traits"),
750
+ }),
751
+ handler: async ({ email, name, company, avatarUrl, metadata }) => {
752
+ await apiPost("/api/identity/stitch", {
753
+ visitorId: `mcp-${email}`,
754
+ email,
755
+ workspaceKey: (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY) || "default",
756
+ metadata: { name, company, avatar_url: avatarUrl, ...metadata, source: "mcp" },
757
+ });
758
+ const person = lowerPerson(await getPersonLabel());
759
+ return {
760
+ content: [{
761
+ type: "text",
762
+ text: `Identity stitched: ${email}${name ? ` (${name})` : ""}${company ? ` at ${company}` : ""}\nA ${person.label} record has been created/linked in Apex.`,
763
+ }],
764
+ };
765
+ },
766
+ },
767
+ get_schema: {
768
+ description: `${APEX} — Read the workspace's canonical Schema (data dictionary): entities, their fields, which fields are wired (observed in live data) vs not, surface coverage, and any canonical mappings. Use to see what variables/attributes a workspace has and what's still unwired before authoring comms or segments.`,
769
+ schema: z.object({}),
770
+ handler: async () => {
771
+ const dict = await apiGet("/api/schema");
772
+ const lines = [];
773
+ for (const entity of Object.values(dict.entities)) {
774
+ lines.push(`\n${entity.label} {{${entity.key}.<field>}}`);
775
+ for (const f of Object.values(entity.fields)) {
776
+ const wired = f.source === "discovered" || Boolean(f.lastSeenAt);
777
+ const canon = f.canonicalName ? ` -> ${f.canonicalName}` : "";
778
+ const surf = f.surfaces?.length ? ` [${f.surfaces.join(",")}]` : "";
779
+ lines.push(` ${wired ? "[wired]" : "[ not ]"} ${f.name}${canon} (${f.type})${surf}`);
780
+ }
781
+ }
782
+ lines.push("\n[wired] = observed in live data [ not ] = canonical field not sent yet");
783
+ return { content: [{ type: "text", text: lines.join("\n") }] };
784
+ },
785
+ },
786
+ map_attribute: {
787
+ description: `${APEX} — Map a discovered field onto its canonical Apex Spec name (e.g. firstName -> first_name) so templates resolve it cleanly at send time. Pass canonicalName: null to clear an existing mapping.`,
788
+ schema: z.object({
789
+ entityKey: z
790
+ .string()
791
+ .describe("Entity key (user, account, order, product, or a custom entity)"),
792
+ fieldName: z
793
+ .string()
794
+ .describe("The field to map, as stored (e.g. 'firstName')"),
795
+ canonicalName: z
796
+ .string()
797
+ .nullable()
798
+ .optional()
799
+ .describe("Canonical snake_case name (e.g. 'first_name'), or null to clear"),
800
+ }),
801
+ handler: async ({ entityKey, fieldName, canonicalName, }) => {
802
+ const res = await apiPost("/api/schema/map", { entityKey, fieldName, canonicalName: canonicalName ?? null });
803
+ const mapped = res.data?.canonicalName;
804
+ return {
805
+ content: [
806
+ {
807
+ type: "text",
808
+ text: mapped
809
+ ? `Mapped ${entityKey}.${fieldName} -> ${mapped}`
810
+ : `Cleared canonical mapping on ${entityKey}.${fieldName}`,
811
+ },
812
+ ],
813
+ };
814
+ },
815
+ },
816
+ define_event: {
817
+ 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.`,
818
+ schema: z.object({
819
+ name: z.string().describe("snake_case event name, not already in the Apex Spec"),
820
+ description: z.string().describe("One sentence: when this event fires"),
821
+ fields: z
822
+ .record(z.string())
823
+ .describe('Field name -> type. Types: "string" | "number" | "boolean" | "object" | "array"; append "?" for optional (e.g. {"widget_id":"string","width":"number?"})'),
824
+ scope: z
825
+ .enum(["workspace", "org"])
826
+ .optional()
827
+ .describe("workspace (default) or org (shared across the org's workspaces)"),
828
+ primary_metric: z
829
+ .boolean()
830
+ .optional()
831
+ .describe("True if this is a conversion/outcome that can be a journey goal"),
832
+ recommended_transport: z
833
+ .enum(["client", "server", "either"])
834
+ .optional()
835
+ .describe("Where to fire it: server for money-truth, client for intent/engagement"),
836
+ }),
837
+ handler: async (args) => {
838
+ const res = await apiPost("/api/schema/events", {
839
+ name: args.name,
840
+ description: args.description,
841
+ fields: args.fields,
842
+ scope: args.scope,
843
+ primaryMetric: args.primaryMetric,
844
+ recommendedTransport: args.recommendedTransport,
845
+ });
846
+ if (res.error) {
847
+ const hint = res.suggestion
848
+ ? ` Use the canonical event "${res.suggestion}" instead.`
849
+ : "";
850
+ return { content: [{ type: "text", text: `Could not define "${args.name}": ${res.error}.${hint}` }] };
851
+ }
852
+ return {
853
+ content: [
854
+ {
855
+ type: "text",
856
+ 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.`,
857
+ },
858
+ ],
859
+ };
860
+ },
861
+ },
862
+ log_prediction: {
863
+ 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.`,
864
+ schema: z.object({
865
+ metric: z.string().describe("The metric being predicted (e.g. 'conversion_rate', 'signup_rate')"),
866
+ expectedChange: z.number().describe("Expected change as a decimal (e.g. 0.15 for +15%)"),
867
+ confidence: z.number().min(0).max(100).describe("How sure you are about this prediction (0-100%)"),
868
+ timeHorizon: z
869
+ .number()
870
+ .optional()
871
+ .describe("Days until the prediction should be evaluated (default: 14)"),
872
+ hypothesisId: z.string().optional().describe("ID of the linked hypothesis"),
873
+ beliefId: z.string().optional().describe("ID of the linked assumption"),
874
+ }),
875
+ handler: async (args) => {
876
+ const direction = args.expectedChange > 0 ? "increase" : args.expectedChange < 0 ? "decrease" : "no_change";
877
+ const prediction = await apiPost("/api/predictions", {
878
+ metric: args.metric,
879
+ expectedChange: args.expectedChange,
880
+ direction,
881
+ expectedRange: {
882
+ low: Math.min(0, args.expectedChange),
883
+ high: Math.max(0, args.expectedChange),
884
+ },
885
+ confidence: args.confidence / 100,
886
+ timeHorizon: args.timeHorizon ?? 14,
887
+ evaluationWindow: args.timeHorizon ?? 14,
888
+ hypothesisId: args.hypothesisId,
889
+ beliefId: args.beliefId,
890
+ });
891
+ const changeStr = `${args.expectedChange > 0 ? "+" : ""}${(args.expectedChange * 100).toFixed(1)}%`;
892
+ return {
893
+ content: [
894
+ {
895
+ type: "text",
896
+ text: [
897
+ `Prediction logged [${prediction.id}]:`,
898
+ ` Metric: ${args.metric}`,
899
+ ` Expected: ${changeStr} ${direction}`,
900
+ ` Confidence: ${args.confidence}%`,
901
+ ` Evaluate in: ${args.timeHorizon ?? 14} days`,
902
+ args.beliefId ? ` Linked assumption: ${args.beliefId}` : "",
903
+ "",
904
+ "This feeds the calibration loop. After the experiment runs, the actual result will be compared to this prediction to track forecasting accuracy.",
905
+ ]
906
+ .filter(Boolean)
907
+ .join("\n"),
908
+ },
909
+ ],
910
+ };
911
+ },
912
+ },
913
+ // `list_workspaces` is canonical; `list_projects` is the deprecated alias
914
+ // kept for the project→workspace rename. Both point at the same handler so
915
+ // existing agent calls keep working.,
916
+ get_event_taxonomy: {
917
+ // MOBX-002 (post-mortem #9) — this tool used to read the legacy
918
+ // comms registry (`/api/communications/events`), which disagreed
919
+ // with the Apex Spec on names AND casing (purchase_completed vs
920
+ // in_app_purchase, userId vs order_id) and rendered "undefined"
921
+ // descriptions. There is ONE event vocabulary: the Apex Spec.
922
+ // This tool now serves it and points agents at get_event_spec.
923
+ description: "DEPRECATED alias of get_event_spec — the canonical Apex Spec event registry (one vocabulary; the legacy comms taxonomy this used to serve disagreed with it). Prefer get_event_spec.",
924
+ schema: z.object({}),
925
+ handler: async () => {
926
+ const res = await apiGet("/api/spec/events");
927
+ const events = res.data?.events ?? {};
928
+ const names = Object.keys(events);
929
+ if (names.length === 0) {
930
+ return { content: [{ type: "text", text: "Spec bundle unavailable. Use get_event_spec." }] };
931
+ }
932
+ const summary = names
933
+ .map((n) => `• ${n} — ${events[n]?.description ?? ""}`)
934
+ .join("\n");
935
+ return {
936
+ content: [{
937
+ type: "text",
938
+ text: `${names.length} canonical events (Apex Spec ${res.data?.specVersion ?? ""}). Use get_event_spec for full schemas.\n\n${summary}`,
939
+ }],
940
+ };
941
+ },
942
+ },
943
+ // ─── Partner Network (MMP-161) ──────────────────────────────────────────
944
+ //
945
+ // Merchant-scoped tools for managing an affiliate program through the
946
+ // Apex Partner Network. Every tool authenticates via the caller's
947
+ // active workspace + API key (same pattern as experiments / comms).
948
+ // Partner-facing flows (self-signup, portal) + admin Fraud Ops are
949
+ // intentionally not wrapped — they require different auth.,
950
+ list_data_sources: {
951
+ description: `${APEX} — List the workspace's Data Sources (the producers that send events: Website, iOS/Android apps, Backend). Shows each source's live status (live / listening / stalled / not-started). Call when the user asks "where is my data coming from?", "which sources are live?", or to audit collection coverage.`,
952
+ schema: z.object({
953
+ includeArchived: z
954
+ .boolean()
955
+ .optional()
956
+ .describe("Include archived sources. Defaults to false."),
957
+ }),
958
+ handler: async ({ includeArchived }) => {
959
+ const proj = getActiveWorkspace();
960
+ const qs = includeArchived ? "?includeArchived=true" : "";
961
+ const json = await apiGet(`/api/workspaces/${proj}/data-sources${qs}`);
962
+ const sources = json.data ?? [];
963
+ if (sources.length === 0) {
964
+ return { content: [{ type: "text", text: "No data sources registered yet. They auto-register on the first event, or add one in Integrations." }] };
965
+ }
966
+ const lines = sources.map((s) => `- **${s.name}** (${s.kind}) — ${s.status}${s.lastSeenAt ? ` · last seen ${s.lastSeenAt}` : ""} · \`${s.id}\``);
967
+ return { content: [{ type: "text", text: `# Data Sources (${sources.length})\n\n${lines.join("\n")}` }] };
968
+ },
969
+ },
970
+ create_data_source: {
971
+ description: `${APEX} — Register a new Data Source in the active workspace. Most sources auto-register on their first event; use this to set one up BEFORE events arrive (e.g. a Backend API source). Reporting-only — registering a source never changes access control.`,
972
+ schema: z.object({
973
+ kind: z
974
+ .enum(["website", "ios", "android", "server", "pos", "kiosk", "extension", "other"])
975
+ .describe("The producer kind."),
976
+ name: z.string().describe("Display name, e.g. 'Marketing site' or 'Backend API'."),
977
+ }),
978
+ handler: async ({ kind, name }) => {
979
+ const proj = getActiveWorkspace();
980
+ const json = await apiPost(`/api/workspaces/${proj}/data-sources`, { kind, name });
981
+ const s = json.data;
982
+ return { content: [{ type: "text", text: `✓ Created data source **${s.name}** (${s.kind}) — ${s.status} · \`${s.id}\`` }] };
983
+ },
984
+ },
985
+ get_data_source_health: {
986
+ description: `${APEX} — Get the live status of one Data Source (live / listening / stalled / not-started) plus its first/last seen timestamps. Use to diagnose "why isn't my backend / app showing data?"`,
987
+ schema: z.object({
988
+ id: z.string().describe("The data source id (ds_...)."),
989
+ }),
990
+ handler: async ({ id }) => {
991
+ const proj = getActiveWorkspace();
992
+ const json = await apiGet(`/api/workspaces/${proj}/data-sources/${encodeURIComponent(id)}/health`);
993
+ const h = json.data;
994
+ return { content: [{ type: "text", text: `# ${h.kind} source ${h.id}\n\n- status: **${h.status}**\n- first seen: ${h.firstSeenAt ?? "never"}\n- last seen: ${h.lastSeenAt ?? "never"}` }] };
995
+ },
996
+ },
997
+ get_analytics_sources: {
998
+ description: `${APEX} — Unified analytics breakdown by Data Source, platform, and hostname over a date range. Use for "how much traffic from each source?", "web vs mobile split", or "which hostnames are busiest?".`,
999
+ schema: z.object({
1000
+ days: z.number().optional().describe("Lookback window in days (default 30, max 365)."),
1001
+ }),
1002
+ handler: async ({ days }) => {
1003
+ const qs = days ? `?days=${days}` : "";
1004
+ const json = await apiGet(`/api/analytics/sources${qs}`);
1005
+ const d = json.data;
1006
+ const srcLines = d.sources.map((s) => `- ${s.name} (${s.kind}, ${s.status}): ${s.eventCount.toLocaleString()} events`);
1007
+ const platLines = d.platforms.map((p) => `- ${p.platform}: ${p.count.toLocaleString()}`);
1008
+ const hostLines = d.hostnames.slice(0, 10).map((h) => `- ${h.hostname}: ${h.count.toLocaleString()}`);
1009
+ return {
1010
+ content: [{ type: "text", text: `# Analytics by source\n\n## Sources\n${srcLines.join("\n") || "_none_"}\n\n## Platforms\n${platLines.join("\n") || "_none_"}\n\n## Top hostnames\n${hostLines.join("\n") || "_none_"}` }],
1011
+ };
1012
+ },
1013
+ },
1014
+ get_analytics_hostnames: {
1015
+ description: `${APEX} — The hostname catalog for this workspace (every host the web snippet has reported, ranked by volume). Use to populate a hostname filter or audit which subdomains are sending events.`,
1016
+ schema: z.object({
1017
+ limit: z.number().optional().describe("Max hostnames to return (default 100)."),
1018
+ }),
1019
+ handler: async ({ limit }) => {
1020
+ const qs = limit ? `?limit=${limit}` : "";
1021
+ const json = await apiGet(`/api/analytics/hostnames${qs}`);
1022
+ const hosts = json.data ?? [];
1023
+ const lines = hosts.map((h) => `- ${h.hostname}: ${h.count.toLocaleString()}`);
1024
+ return { content: [{ type: "text", text: hosts.length ? `# Hostnames (${hosts.length})\n\n${lines.join("\n")}` : "No hostnames seen yet." }] };
1025
+ },
1026
+ },
1027
+ get_event_spec: {
1028
+ description: `${APEX} — The canonical event reference for instrumenting Apex tracking: every event name, its fields (with required/optional markers), and example payloads, as a JSON Schema bundle. Call this BEFORE wiring apex.track()/Apex.track() calls or Server Events API posts into a codebase so you use canonical names and field shapes. Pair with get_wiring_status to verify your instrumentation afterwards.`,
1029
+ schema: z.object({
1030
+ event: z.string().optional().describe("Return only this event's schema (canonical name, e.g. 'add_to_cart'). Omit for the full bundle."),
1031
+ }),
1032
+ handler: async ({ event }) => {
1033
+ const res = await apiGet("/api/spec/events");
1034
+ const bundle = res.data ?? {};
1035
+ if (event) {
1036
+ const all = (bundle.events ?? {});
1037
+ const match = all[event];
1038
+ if (!match) {
1039
+ const names = Object.keys(all).slice(0, 50).join(", ");
1040
+ return {
1041
+ content: [{ type: "text", text: `Unknown event "${event}". Known events include: ${names}` }],
1042
+ };
1043
+ }
1044
+ return {
1045
+ content: [{ type: "text", text: `# ${event}\n\n\`\`\`json\n${JSON.stringify(match, null, 2)}\n\`\`\`` }],
1046
+ };
1047
+ }
1048
+ return {
1049
+ content: [{ type: "text", text: `# Apex event reference\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`` }],
1050
+ };
1051
+ },
1052
+ },
1053
+ get_wiring_status: {
1054
+ description: `${APEX} — Live wiring verification for the active workspace: which canonical events have been received (tier: waiting → detected → verified), which identify traits (first_name, email, …) are arriving, per-data-source status, and which journey/communication templates are Ready to run. Call this AFTER instrumenting tracking code to verify your own work — iterate until each wired event reports "detected". Presence-only: never returns end-user values.`,
1055
+ schema: z.object({}),
1056
+ handler: async () => {
1057
+ const workspaceKey = getActiveWorkspace();
1058
+ if (!workspaceKey) {
1059
+ return {
1060
+ content: [{ type: "text", text: "No active workspace. Use switch_workspace first (or set APEX_WORKSPACE_KEY)." }],
1061
+ };
1062
+ }
1063
+ const json = await apiGet(`/api/workspaces/${encodeURIComponent(workspaceKey)}/readiness`);
1064
+ const d = json.data;
1065
+ const person = lowerPerson(await getPersonLabel());
1066
+ const eventLines = d.events.map((e) => `- ${e.name}: ${e.tier} (${e.count.toLocaleString()} events, platforms: ${e.platforms.join("/") || "—"})${e.observedFields?.length ? ` — fields seen: ${e.observedFields.join(", ")}` : ""}`);
1067
+ const traitLines = d.traits.map((t) => `- ${t.name}: ${t.present ? "arriving" : "not seen"}${t.inferredType ? ` (${t.inferredType})` : ""}`);
1068
+ const sourceLines = d.sources.map((s) => `- ${s.name} (${s.kind}): ${s.status}${s.lastSeenAt ? ` — last event ${s.lastSeenAt}` : ""}`);
1069
+ const readyTemplates = d.templates.filter((t) => t.ready);
1070
+ const blockedTemplates = d.templates.filter((t) => !t.ready);
1071
+ const templateLines = [
1072
+ ...readyTemplates.map((t) => `- ✅ ${t.title} (${t.templateKind}, id: ${t.templateId}) — Ready to run${t.templateKind === "journey" ? ` → create_journey_from_template({ templateId: "${t.templateId}" })` : ""}`),
1073
+ ...blockedTemplates.slice(0, 10).map((t) => `- ⏳ ${t.title} (${t.templateKind}, id: ${t.templateId}) — needs ${[...t.missingEvents, ...t.missingTraits].join(", ") || "?"}`),
1074
+ ];
1075
+ const milestones = [
1076
+ d.milestones.firstEventAt ? `First event: ${d.milestones.firstEventAt}` : "First event: not yet",
1077
+ d.milestones.firstIdentifiedAt
1078
+ ? `First identified ${person.label}: ${d.milestones.firstIdentifiedAt}`
1079
+ : `First identified ${person.label}: not yet`,
1080
+ ];
1081
+ return {
1082
+ content: [{
1083
+ type: "text",
1084
+ text: `# Wiring status — ${workspaceKey}\n\n## Milestones\n${milestones.map((m) => `- ${m}`).join("\n")}\n\n## Events received\n${eventLines.join("\n") || "_none yet_"}\n\n## Identify traits\n${traitLines.join("\n") || "_none yet_"}\n\n## Data sources\n${sourceLines.join("\n") || "_none yet_"}\n\n## Templates\n${templateLines.join("\n") || "_none derivable yet_"}`,
1085
+ }],
1086
+ };
1087
+ },
1088
+ },
1089
+ propose_wiring_plan: {
1090
+ description: `${APEX} — Report the Apex Spec instrumentation opportunities you found by SCANNING the merchant's codebase (auth flows → identify + user_signed_up, checkout → purchase events, forms → form_submit, app purchases → in_app_purchase, server order paths → order_placed, …). Call this BEFORE wiring: the plan appears on the merchant's setup page as pending lights, each flipping green when its first real event arrives. Structure your scan report in three sections: (1) ALREADY WIRED — events the repo already instruments (include them here with wired: true; they're awaiting their first heartbeat), (2) GAPS TO WIRE — the work you propose, (3) WON'T WIRE + WHY — opportunities you're deliberately skipping (e.g. a server event with no backend, or a form that already fires a richer dedicated event). Then instrument each gap where the truth lives and verify with get_wiring_status. Scan first; never ask the merchant to enumerate events for you.`,
1091
+ schema: z.object({
1092
+ events: z
1093
+ .array(z.object({
1094
+ name: z
1095
+ .string()
1096
+ .describe("Canonical snake_case event name from the Apex Spec (use get_event_spec), or a custom snake_case name."),
1097
+ surface: z
1098
+ .enum(["web", "mobile", "server"])
1099
+ .describe("Where this event should fire FROM — wire each event where the truth lives."),
1100
+ note: z
1101
+ .string()
1102
+ .optional()
1103
+ .describe("Short pointer to where you found the opportunity, e.g. 'checkout/confirm.ts handlePayment()'."),
1104
+ wired: z
1105
+ .boolean()
1106
+ .optional()
1107
+ .describe("True when the repo ALREADY instruments this event (awaiting its first heartbeat) — the merchant's board labels it 'wired' instead of 'agent plan'."),
1108
+ }))
1109
+ .min(1)
1110
+ .describe("Every instrumentation opportunity found in the scan — both already-wired events (wired: true) and the gaps you propose to wire."),
1111
+ summary: z.string().optional().describe("One-paragraph scan summary for the merchant — include what you're deliberately NOT wiring and why."),
1112
+ }),
1113
+ handler: async (args) => {
1114
+ const workspaceKey = getActiveWorkspace();
1115
+ if (!workspaceKey) {
1116
+ return {
1117
+ content: [{ type: "text", text: "No active workspace. Use switch_workspace first (or set APEX_WORKSPACE_KEY)." }],
1118
+ };
1119
+ }
1120
+ const json = await apiPost(`/api/workspaces/${encodeURIComponent(workspaceKey)}/wiring-plan`, {
1121
+ events: args.events,
1122
+ summary: args.summary,
1123
+ source: "mcp",
1124
+ });
1125
+ const lines = json.data.events.map((e) => `- ${e.name} (${e.surface})${e.wired ? " — already wired, awaiting first heartbeat" : ""}`);
1126
+ return {
1127
+ content: [{
1128
+ type: "text",
1129
+ text: `# Wiring plan proposed — ${workspaceKey}\n\nThe merchant's setup page now shows ${json.data.events.length} pending event light${json.data.events.length === 1 ? "" : "s"}:\n${lines.join("\n")}\n\nNext: instrument each gap where it happens in the code (smallest change, never commit), then call get_wiring_status until every light reports "detected".`,
1130
+ }],
1131
+ };
1132
+ },
1133
+ },
1134
+ // ─── Graduation Pipeline — Engineering Links (Wave 2) ─────────────────,
1135
+ apex_test_event: {
1136
+ description: `${APEX} — Send a test event through the public ingestion endpoint and confirm it was accepted. The fastest way to verify event tracking works after installing the SDK.`,
1137
+ schema: z.object({
1138
+ eventName: z.string().describe("Event name, e.g. user_signed_up"),
1139
+ attributes: z.record(z.unknown()).optional().describe("Event attributes"),
1140
+ visitorId: z.string().optional().describe("Visitor id (default a test id)"),
1141
+ }),
1142
+ handler: async (args) => {
1143
+ const workspaceKey = getActiveWorkspace();
1144
+ if (!workspaceKey) {
1145
+ return { content: [{ type: "text", text: "No active workspace. Run /apex-login or set APEX_WORKSPACE_KEY." }] };
1146
+ }
1147
+ const visitorId = args.visitorId ?? `test-${Date.now()}`;
1148
+ const res = await apiPost("/api/events", {
1149
+ workspaceKey,
1150
+ type: args.eventName,
1151
+ visitorId,
1152
+ timestamp: new Date().toISOString(),
1153
+ data: { ...(args.attributes ?? {}), endUserId: visitorId },
1154
+ });
1155
+ const text = res.eventId
1156
+ ? `Test event "${args.eventName}" accepted (id ${res.eventId}). It's flowing into ${workspaceKey}.`
1157
+ : `Event rejected: ${res.error ?? "unknown error"}`;
1158
+ return { content: [{ type: "text", text }] };
1159
+ },
1160
+ },
1161
+ apex_recent_events: {
1162
+ description: `${APEX} — List recent events for the workspace (optionally filtered by type). Use to debug SDK wiring without opening the dashboard.`,
1163
+ schema: z.object({
1164
+ type: z.string().optional().describe("Filter by event type"),
1165
+ sinceMinutes: z.number().optional().describe("Lookback window in minutes (default 60)"),
1166
+ }),
1167
+ handler: async (args) => {
1168
+ const qs = new URLSearchParams();
1169
+ if (args.type)
1170
+ qs.set("type", args.type);
1171
+ const since = new Date(Date.now() - (args.sinceMinutes ?? 60) * 60_000).toISOString();
1172
+ qs.set("since", since);
1173
+ qs.set("limit", "25");
1174
+ const res = await apiGet(`/api/events/live?${qs.toString()}`);
1175
+ const events = res.events ?? [];
1176
+ if (events.length === 0) {
1177
+ return { content: [{ type: "text", text: "No recent events in that window." }] };
1178
+ }
1179
+ const lines = events.slice(0, 25).map((e) => `- ${e.timestamp} ${e.type}${e.visitorId ? ` (${e.visitorId})` : ""}`);
1180
+ return { content: [{ type: "text", text: `# Recent events\n${lines.join("\n")}` }] };
1181
+ },
1182
+ },
1183
+ apex_health: {
1184
+ description: `${APEX} — One-shot health check of every connected integration (last sync, status). Use before adding a feature that depends on a connector.`,
1185
+ schema: z.object({}),
1186
+ handler: async () => {
1187
+ // GET /api/integrations is the connector LIST. `/api/connectors/*` only
1188
+ // has per-type auth + callback routes, so the old path 404'd and this
1189
+ // tool reported "No connected integrations" for every workspace.
1190
+ const connectors = await apiGet("/api/integrations");
1191
+ const list = Array.isArray(connectors) ? connectors : [];
1192
+ const connected = list.filter((c) => c.isConnected || c.status === "connected");
1193
+ if (connected.length === 0) {
1194
+ // Say which of the two it is. `apiGet` throws on a failed call, so
1195
+ // reaching here means the list really came back and really was empty.
1196
+ return {
1197
+ content: [
1198
+ {
1199
+ type: "text",
1200
+ text: `No connected integrations (the workspace returned ${list.length} available connector${list.length === 1 ? "" : "s"}, none connected).`,
1201
+ },
1202
+ ],
1203
+ };
1204
+ }
1205
+ const lines = connected.map((c) => `- ${c.name ?? c.type}: ${c.status ?? "connected"}${c.lastSyncAt ? ` (last sync ${c.lastSyncAt})` : ""}`);
1206
+ return { content: [{ type: "text", text: `# Integration health\n${lines.join("\n")}` }] };
1207
+ },
1208
+ },
1209
+ apex_spec_validate: {
1210
+ description: `${APEX} — Fetch the canonical Apex Spec event taxonomy so you can validate the track()/identify()/useApexVariant() calls in the user's files against it (ESLint-like for Apex usage). Read the user's files, find the calls, and flag event names + attributes that don't match the returned spec.`,
1211
+ schema: z.object({}),
1212
+ handler: async () => {
1213
+ const spec = await apiGet("/api/spec/events");
1214
+ return {
1215
+ content: [
1216
+ {
1217
+ type: "text",
1218
+ text: `Apex Spec (validate the user's track/identify/useApexVariant calls against this — flag unknown event names + attribute shapes):\n\n${JSON.stringify(spec, null, 2)}`,
1219
+ },
1220
+ ],
1221
+ };
1222
+ },
1223
+ },
1224
+ list_conversions: {
1225
+ description: `${APEX} — List the workspace's Conversions (owned-event definitions of valued actions, e.g. "Purchase", "Signup"). Call this when the user asks what conversions are defined. The starred primary is a label for that Conversion — it does not pick the funnel stages and it is not the experiment default. Experiments name their own metric; the funnel follows the workspace conversion model.`,
1226
+ schema: z.object({}),
1227
+ handler: async () => {
1228
+ const conversions = await apiGet("/api/conversion-goals");
1229
+ const active = conversions.filter((c) => !c.archivedAt);
1230
+ if (active.length === 0) {
1231
+ return {
1232
+ content: [
1233
+ {
1234
+ type: "text",
1235
+ text: "No conversions defined yet. Use create_conversion to define one (e.g. a custom_event named 'purchase').",
1236
+ },
1237
+ ],
1238
+ };
1239
+ }
1240
+ const lines = active.map((c) => `• ${c.name} (${c.id}) — ${c.type}${c.eventName ? ` [${c.eventName}]` : ""}${c.isPrimary ? " ★ primary" : ""}`);
1241
+ return {
1242
+ content: [
1243
+ {
1244
+ type: "text",
1245
+ text: `${active.length} conversion${active.length === 1 ? "" : "s"}:\n\n${lines.join("\n")}`,
1246
+ },
1247
+ ],
1248
+ };
1249
+ },
1250
+ },
1251
+ create_conversion: {
1252
+ 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. "order_placed", "user_signed_up") matching an Apex Spec event you send via track. isPrimary stars this Conversion as a label — it does not pick experiment defaults or drive the funnel.`,
1253
+ schema: z.object({
1254
+ name: z.string().describe('Human label, e.g. "Purchase" or "Signup"'),
1255
+ type: z
1256
+ .enum([
1257
+ "form_submit",
1258
+ "click",
1259
+ "pageview",
1260
+ "custom_event",
1261
+ "file_download",
1262
+ "engaged_time",
1263
+ ])
1264
+ .describe("How the conversion is detected. custom_event is the most common (set eventName)."),
1265
+ eventName: z
1266
+ .string()
1267
+ .optional()
1268
+ .describe("For custom_event: the Apex Spec event name to match, e.g. 'purchase'."),
1269
+ selector: z
1270
+ .string()
1271
+ .optional()
1272
+ .describe("For click/form_submit: a CSS selector to match."),
1273
+ targetUrl: z
1274
+ .string()
1275
+ .optional()
1276
+ .describe("For pageview: the URL (or path) that counts as a completion."),
1277
+ isPrimary: z
1278
+ .boolean()
1279
+ .optional()
1280
+ .describe("Make this the primary conversion (only one can be primary)."),
1281
+ }),
1282
+ handler: async (args) => {
1283
+ const payload = {
1284
+ name: args.name,
1285
+ type: args.type,
1286
+ metricType: "binary",
1287
+ isPrimary: args.isPrimary ?? false,
1288
+ ...(args.eventName ? { eventName: args.eventName } : {}),
1289
+ ...(args.selector ? { selector: args.selector } : {}),
1290
+ ...(args.targetUrl ? { targetUrl: args.targetUrl } : {}),
1291
+ };
1292
+ const data = await apiPost("/api/conversion-goals", payload);
1293
+ return {
1294
+ content: [
1295
+ {
1296
+ type: "text",
1297
+ text: `Conversion created.\n\n${JSON.stringify(data, null, 2)}`,
1298
+ },
1299
+ ],
1300
+ };
1301
+ },
1302
+ },
1303
+ set_primary_conversion: {
1304
+ description: `${APEX} — Mark a Conversion as the starred primary. That star labels the Conversion — it does not set experiment defaults or drive the funnel. Experiments name their own metric. Pass the conversion id from list_conversions.`,
1305
+ schema: z.object({
1306
+ conversionId: z.string().describe("The conversion id to promote (from list_conversions)."),
1307
+ }),
1308
+ handler: async ({ conversionId }) => {
1309
+ const data = await apiPatch("/api/conversion-goals", { id: conversionId, isPrimary: true });
1310
+ return {
1311
+ content: [
1312
+ {
1313
+ type: "text",
1314
+ text: `Primary conversion set.\n\n${JSON.stringify(data, null, 2)}`,
1315
+ },
1316
+ ],
1317
+ };
1318
+ },
1319
+ },
1320
+ create_target: {
1321
+ 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.`,
1322
+ schema: z.object({
1323
+ metric: z.string().describe('What you\'re tracking, e.g. "MRR", "Signups", "Pipeline".'),
1324
+ target: z.number().describe("The numeric goal to reach."),
1325
+ deadline: z.string().describe('Target date, ISO "YYYY-MM-DD".'),
1326
+ sourceConversionId: z
1327
+ .string()
1328
+ .optional()
1329
+ .describe("Optional: bind to a Conversion (from list_conversions) to auto-track progress."),
1330
+ }),
1331
+ handler: async (args) => {
1332
+ const payload = {
1333
+ metric: args.metric,
1334
+ target: args.target,
1335
+ deadline: args.deadline,
1336
+ ...(args.sourceConversionId
1337
+ ? { sourceConversionId: args.sourceConversionId }
1338
+ : {}),
1339
+ };
1340
+ const data = await apiPost("/api/targets", payload);
1341
+ return {
1342
+ content: [
1343
+ {
1344
+ type: "text",
1345
+ text: `Target created.\n\n${JSON.stringify(data, null, 2)}`,
1346
+ },
1347
+ ],
1348
+ };
1349
+ },
1350
+ },
1351
+ // ─── Adoption Engine ──────────────────────────────────────────────────────,
1352
+ };
1353
+ //# sourceMappingURL=measurement.js.map