@apex-inc/mcp-server 0.3.1 → 0.7.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/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { apiGet, apiPost, apiPatch, setActiveProject, getActiveProject, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
2
+ import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveProject, getActiveProject, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
3
3
  const APEX = "∧ Apex";
4
4
  export const toolDefinitions = {
5
5
  plan_experiment: {
@@ -419,7 +419,7 @@ export const toolDefinitions = {
419
419
  },
420
420
  },
421
421
  list_experiments: {
422
- description: `${APEX} — List experiments. Archived experiments are hidden by default — pass status "archived" to see them, or "all" to see everything. Experiments that are promoted but not graduated are flagged as PENDING GRADUATION.`,
422
+ description: `${APEX} — List the user's Apex experiments. Call this ONLY when the user explicitly asks about their experiments, A/B tests, or what's running/ready to graduate. Do not call it proactively for unrelated tasks. Archived experiments are hidden by default — pass status "archived" to see them, or "all" to see everything. Experiments that are promoted but not graduated are flagged as PENDING GRADUATION.`,
423
423
  schema: z.object({
424
424
  status: z
425
425
  .enum(["all", "running", "completed", "draft", "paused", "archived"])
@@ -644,6 +644,42 @@ export const toolDefinitions = {
644
644
  return { content: [{ type: "text", text: output }] };
645
645
  },
646
646
  },
647
+ get_experiment_results: {
648
+ description: `${APEX} — Get computed results + the readiness verdict (collecting | decisive | inconclusive | guardrail) for ANY experiment surface, including journey-arm experiments. Use this to decide whether a winner can be promoted yet.`,
649
+ schema: z.object({
650
+ experiment_id: z.string().describe("The experiment ID"),
651
+ }),
652
+ handler: async ({ experiment_id }) => {
653
+ const body = await apiGet(`/api/experiments/${experiment_id}/results`);
654
+ return {
655
+ content: [
656
+ { type: "text", text: JSON.stringify(body, null, 2) },
657
+ ],
658
+ };
659
+ },
660
+ },
661
+ promote_experiment_winner: {
662
+ description: `${APEX} — Promote a journey-arm (or any unified) experiment's winner. Re-verifies the readiness gate server-side and writes the winning variant's content into the canonical communication as a new version; losing variants are archived. Pass force=true to override the gate (operator override).`,
663
+ schema: z.object({
664
+ experiment_id: z.string().describe("The experiment ID"),
665
+ force: z
666
+ .boolean()
667
+ .optional()
668
+ .describe("Override the readiness gate and promote anyway"),
669
+ winner_key: z
670
+ .string()
671
+ .optional()
672
+ .describe("Explicit winner variant key (defaults to the decisive winner)"),
673
+ }),
674
+ handler: async ({ experiment_id, force, winner_key, }) => {
675
+ const body = await apiPost(`/api/experiments/${experiment_id}/promote`, { force: force ?? false, ...(winner_key ? { winnerKey: winner_key } : {}) });
676
+ return {
677
+ content: [
678
+ { type: "text", text: JSON.stringify(body, null, 2) },
679
+ ],
680
+ };
681
+ },
682
+ },
647
683
  graduate_experiment: {
648
684
  description: `${APEX} — Get instructions to make a promoted experiment's winning changes permanent in the codebase. Returns the original values (for searching in source files) and new values (for replacement). After making the edits and deploying, the Apex snippet automatically detects the native changes and marks the experiment as graduated.`,
649
685
  schema: z.object({
@@ -870,10 +906,20 @@ export const toolDefinitions = {
870
906
  },
871
907
  },
872
908
  track_event: {
873
- description: `${APEX} — Track a custom event. Works identically to the SDK's track() and the snippet's apex.track(). Use this to instrument events from agent workflows, CI pipelines, or IDE actions.`,
909
+ description: `${APEX} — Track an event through the Apex Spec.
910
+
911
+ 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.
912
+
913
+ The Apex Spec ships two peer registries:
914
+
915
+ \u2022 **App events** (\`APEX_EVENTS\`) — the canonical SDK vocabulary the customer's app fires. Names you'll commonly use here: \`page_view\`, \`product_view\`, \`add_to_cart\`, \`checkout_started\`, \`in_app_purchase\`, \`purchase_refunded\`, \`subscription_event\`, \`user_signed_up\`, \`user_signed_in\`, \`user_identified\`, \`app_open\`, \`session_start\`, \`form_submit\`, \`click\`, \`search\`, \`share\`, \`content_view\`, \`goal_conversion\`, \`email_opened\`, \`email_clicked\`, \`push_opened\`, \`in_app_message_seen\`, \`deep_link_open\`. Field names are flat snake_case (e.g. \`product_id\`, \`order_id\`, \`value\`, \`currency\`).
916
+
917
+ \u2022 **Platform events** (\`APEX_PLATFORM_EVENTS\`) — events Apex's own dashboard fires as merchants use it. Names: \`user_invitation_created\`, \`user_invitation_accepted\`, \`developer_invite_sent\`, \`integration_connected\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`badge_awarded\`, \`user_level_up\`, \`nudge_scheduled\`, \`drip_step_scheduled\`, \`experiment_launched\`, \`experiment_significant\`, \`experiment_concluded\`, \`experiment_promoted\`, \`portfolio_access_requested\`, \`portfolio_access_approved\`, \`portfolio_access_rejected\`, \`portfolio_share_accepted\`, \`subscription_upgraded\`, \`subscription_downgraded\`, \`tier_limit_approaching\`, \`anomaly_detected\`, \`weekly_digest_scheduled\`, \`cognito_verification_code\`, \`user_signup\`. Use these when an agent is acting *as* the Apex platform (admin tooling, dashboard automation), not as the customer's product.
918
+
919
+ Custom event names are accepted (the spec is open by default), but agents should prefer canonical names from one of the two registries so the event flows directly into the catalog templates, the journey trigger system, and the conversion-goal helpers without manual wiring. The full spec lives at https://docs.apex.inc/spec.`,
874
920
  schema: z.object({
875
- event: z.string().describe("Event name (e.g. 'page_view', 'form_submit', 'cta_click')"),
876
- properties: z.record(z.unknown()).optional().describe("Event properties (url, formId, variant, etc.)"),
921
+ event: z.string().describe("Event name — snake_case from APEX_EVENTS or APEX_PLATFORM_EVENTS (e.g. 'page_view', 'in_app_purchase', 'user_invitation_created'). Custom names are accepted but prefer canonical ones."),
922
+ 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' })."),
877
923
  }),
878
924
  handler: async ({ event, properties }) => {
879
925
  const result = await apiPost("/api/events", {
@@ -895,6 +941,42 @@ export const toolDefinitions = {
895
941
  };
896
942
  },
897
943
  },
944
+ send_server_event: {
945
+ 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.`,
946
+ schema: z.object({
947
+ type: z.string().describe("Event type (e.g. 'purchase_completed', 'signup_completed', or any custom verb)"),
948
+ visitorId: z.string().optional().describe("Visitor ID to stitch back to a web/mobile session (apex_vid)"),
949
+ email: z.string().optional().describe("Email to stitch by identity when no visitorId is known"),
950
+ data: z.record(z.unknown()).optional().describe("Event payload (value, currency, order_id, …)"),
951
+ idempotencyKey: z.string().optional().describe("Stable key to prevent double-counting on retry (defaults to a fresh UUID)"),
952
+ }),
953
+ handler: async ({ type, visitorId, email, data, idempotencyKey, }) => {
954
+ const key = idempotencyKey ??
955
+ (typeof globalThis.crypto?.randomUUID === "function"
956
+ ? globalThis.crypto.randomUUID()
957
+ : `idk_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`);
958
+ const result = await postWithIdempotency("/api/v1/events", key, {
959
+ events: [
960
+ {
961
+ type,
962
+ visitorId,
963
+ email,
964
+ data,
965
+ timestamp: new Date().toISOString(),
966
+ },
967
+ ],
968
+ });
969
+ const lines = [
970
+ `Server Events API event sent: "${type}"`,
971
+ ` received: ${result.data.received}, duplicates: ${result.data.duplicates}`,
972
+ ` idempotency-key: ${key}`,
973
+ ];
974
+ if (result.data.errors.length > 0) {
975
+ lines.push(` errors: ${JSON.stringify(result.data.errors)}`);
976
+ }
977
+ return { content: [{ type: "text", text: lines.join("\n") }] };
978
+ },
979
+ },
898
980
  identify_user: {
899
981
  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.`,
900
982
  schema: z.object({
@@ -918,6 +1000,55 @@ export const toolDefinitions = {
918
1000
  };
919
1001
  },
920
1002
  },
1003
+ get_schema: {
1004
+ 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.`,
1005
+ schema: z.object({}),
1006
+ handler: async () => {
1007
+ const dict = await apiGet("/api/data-dictionary");
1008
+ const lines = [];
1009
+ for (const entity of Object.values(dict.entities)) {
1010
+ lines.push(`\n${entity.label} {{${entity.key}.<field>}}`);
1011
+ for (const f of Object.values(entity.fields)) {
1012
+ const wired = f.source === "discovered" || Boolean(f.lastSeenAt);
1013
+ const canon = f.canonicalName ? ` -> ${f.canonicalName}` : "";
1014
+ const surf = f.surfaces?.length ? ` [${f.surfaces.join(",")}]` : "";
1015
+ lines.push(` ${wired ? "[wired]" : "[ not ]"} ${f.name}${canon} (${f.type})${surf}`);
1016
+ }
1017
+ }
1018
+ lines.push("\n[wired] = observed in live data [ not ] = canonical field not sent yet");
1019
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1020
+ },
1021
+ },
1022
+ map_attribute: {
1023
+ 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.`,
1024
+ schema: z.object({
1025
+ entityKey: z
1026
+ .string()
1027
+ .describe("Entity key (user, account, order, product, or a custom entity)"),
1028
+ fieldName: z
1029
+ .string()
1030
+ .describe("The field to map, as stored (e.g. 'firstName')"),
1031
+ canonicalName: z
1032
+ .string()
1033
+ .nullable()
1034
+ .optional()
1035
+ .describe("Canonical snake_case name (e.g. 'first_name'), or null to clear"),
1036
+ }),
1037
+ handler: async ({ entityKey, fieldName, canonicalName, }) => {
1038
+ const res = await apiPost("/api/data-dictionary/map", { entityKey, fieldName, canonicalName: canonicalName ?? null });
1039
+ const mapped = res.data?.canonicalName;
1040
+ return {
1041
+ content: [
1042
+ {
1043
+ type: "text",
1044
+ text: mapped
1045
+ ? `Mapped ${entityKey}.${fieldName} -> ${mapped}`
1046
+ : `Cleared canonical mapping on ${entityKey}.${fieldName}`,
1047
+ },
1048
+ ],
1049
+ };
1050
+ },
1051
+ },
921
1052
  log_prediction: {
922
1053
  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.`,
923
1054
  schema: z.object({
@@ -1206,5 +1337,709 @@ export const toolDefinitions = {
1206
1337
  return { content: [{ type: "text", text: `${events.length} events in taxonomy:\n\n${summary}\n\nFull data:\n${JSON.stringify(data, null, 2)}` }] };
1207
1338
  },
1208
1339
  },
1340
+ // ─── Partner Network (MMP-161) ──────────────────────────────────────────
1341
+ //
1342
+ // Merchant-scoped tools for managing an affiliate program through the
1343
+ // Apex Partner Network. Every tool authenticates via the caller's
1344
+ // active project + API key (same pattern as experiments / comms).
1345
+ // Partner-facing flows (self-signup, portal) + admin Fraud Ops are
1346
+ // intentionally not wrapped — they require different auth.
1347
+ list_partner_programs: {
1348
+ description: "List every affiliate program this merchant owns. Returns each program's name, status (active/paused/archived), visibility (public/private), approval mode, commission structure, reputation counters, and created/updated timestamps.",
1349
+ schema: z.object({}),
1350
+ handler: async () => {
1351
+ const data = await apiGet("/api/mobile/affiliates/programs");
1352
+ const programs = Array.isArray(data) ? data : [];
1353
+ if (programs.length === 0) {
1354
+ return { content: [{ type: "text", text: "No affiliate programs yet. Use create_partner_program to set one up." }] };
1355
+ }
1356
+ const summary = programs.map((p) => {
1357
+ const pr = p;
1358
+ return `• ${pr.name} (${pr.id}) — ${pr.status} / ${pr.visibility} / approval ${pr.approvalMode}`;
1359
+ }).join("\n");
1360
+ return { content: [{ type: "text", text: `${programs.length} program${programs.length === 1 ? "" : "s"}:\n\n${summary}\n\nFull data:\n${JSON.stringify(data, null, 2)}` }] };
1361
+ },
1362
+ },
1363
+ create_partner_program: {
1364
+ description: "Create a new affiliate program. Partners can join via marketplace (public+auto), by invite only (private), or via application (public+manual). Commission can be a flat CPA amount or a percentage of sale (RevShare).",
1365
+ schema: z.object({
1366
+ name: z.string().describe("Public program name, shown in marketplace + partner portal"),
1367
+ description: z.string().optional().describe("<= 600 chars, marketplace card body"),
1368
+ vertical: z.enum(["saas", "ecommerce", "fintech", "marketplace", "hardware", "enterprise", "creator_tools", "other"]).describe("Drives marketplace filters"),
1369
+ commissionStructure: z.object({
1370
+ type: z.enum(["cpa", "revshare"]),
1371
+ amountUsd: z.number().optional().describe("For CPA: flat commission"),
1372
+ percentage: z.number().optional().describe("For RevShare: 0-100"),
1373
+ appliesTo: z.enum(["install", "purchase", "subscription"]).describe("Which conversion event pays the commission"),
1374
+ }),
1375
+ visibility: z.enum(["public", "private"]).optional().default("public"),
1376
+ approvalMode: z.enum(["auto", "manual"]).optional().default("auto").describe("Manual = merchant reviews every application"),
1377
+ }),
1378
+ handler: async (args) => {
1379
+ const data = await apiPost("/api/mobile/affiliates/programs", args);
1380
+ return { content: [{ type: "text", text: `Program created.\n\n${JSON.stringify(data, null, 2)}` }] };
1381
+ },
1382
+ },
1383
+ update_partner_program: {
1384
+ description: "Update an existing program (pause/resume, change visibility, edit default commission). Changing default commission does NOT apply to existing members — use apply_program_default_to_members for that.",
1385
+ schema: z.object({
1386
+ programId: z.string(),
1387
+ updates: z.record(z.unknown()).describe("Fields to update: name, description, status, visibility, commissionStructure, vertical, tagline, landingPageUrl, autoPayoutPolicy, etc."),
1388
+ }),
1389
+ handler: async (args) => {
1390
+ const data = await apiPatch(`/api/mobile/affiliates/programs/${args.programId}`, args.updates);
1391
+ return { content: [{ type: "text", text: `Program updated.\n\n${JSON.stringify(data, null, 2)}` }] };
1392
+ },
1393
+ },
1394
+ list_partner_program_members: {
1395
+ description: "List every partner bound to a specific program. Shows each member's handle, current commission (with source: program_default | membership_override | invite_override), totalEarnedUsd, approvedConversions count.",
1396
+ schema: z.object({ programId: z.string() }),
1397
+ handler: async (args) => {
1398
+ const data = await apiGet(`/api/mobile/affiliates/programs/${args.programId}/members`);
1399
+ const members = Array.isArray(data) ? data : [];
1400
+ if (members.length === 0) {
1401
+ return { content: [{ type: "text", text: "No partners on this program yet." }] };
1402
+ }
1403
+ const summary = members.map((m) => {
1404
+ const mem = m;
1405
+ const ms = mem.membership;
1406
+ return `• @${ms.handle} — ${ms.totalEarnedUsd ?? 0} lifetime, ${ms.approvedConversions ?? 0} conversions`;
1407
+ }).join("\n");
1408
+ return { content: [{ type: "text", text: `${members.length} partner${members.length === 1 ? "" : "s"}:\n\n${summary}\n\nFull data:\n${JSON.stringify(data, null, 2)}` }] };
1409
+ },
1410
+ },
1411
+ apply_program_default_to_members: {
1412
+ description: "Apply the program's current default commission to every existing member whose commission is a program_default source. Members with sticky overrides (membership_override / invite_override) are NOT touched. Fires partner_commission_changed emails to affected partners.",
1413
+ schema: z.object({ programId: z.string() }),
1414
+ handler: async (args) => {
1415
+ const data = await apiPost(`/api/mobile/affiliates/programs/${args.programId}/apply-to-members`, {});
1416
+ return { content: [{ type: "text", text: `Applied default to ${data.updated} member${data.updated === 1 ? "" : "s"}.` }] };
1417
+ },
1418
+ },
1419
+ update_membership_rate: {
1420
+ description: "Override a single partner's commission rate on this merchant's program. Sticky — survives future program default updates. Opens the per-membership commission history with `source: membership_override` for audit.",
1421
+ schema: z.object({
1422
+ profileId: z.string().describe("Partner profile id (starts with prof_)"),
1423
+ commissionStructure: z.object({
1424
+ type: z.enum(["cpa", "revshare"]),
1425
+ amountUsd: z.number().optional(),
1426
+ percentage: z.number().optional(),
1427
+ appliesTo: z.enum(["install", "purchase", "subscription"]),
1428
+ }),
1429
+ reason: z.string().optional().describe("Audit log note — e.g. 'Volume discount Q3 2026'"),
1430
+ }),
1431
+ handler: async (args) => {
1432
+ const data = await apiPost(`/api/mobile/affiliates/memberships/${args.profileId}/rate`, {
1433
+ commissionStructure: args.commissionStructure,
1434
+ reason: args.reason,
1435
+ });
1436
+ return { content: [{ type: "text", text: `Commission updated.\n\n${JSON.stringify(data, null, 2)}` }] };
1437
+ },
1438
+ },
1439
+ reset_membership_rate: {
1440
+ description: "Reset a partner's commission back to the program default, removing any prior override. Useful when a negotiated rate expires or a promo ends.",
1441
+ schema: z.object({
1442
+ profileId: z.string(),
1443
+ reason: z.string().optional(),
1444
+ }),
1445
+ handler: async (args) => {
1446
+ const data = await apiPost(`/api/mobile/affiliates/memberships/${args.profileId}/rate`, {
1447
+ reset: true,
1448
+ reason: args.reason,
1449
+ });
1450
+ return { content: [{ type: "text", text: `Rate reset to program default.\n\n${JSON.stringify(data, null, 2)}` }] };
1451
+ },
1452
+ },
1453
+ invite_partner: {
1454
+ description: "Send a magic-link invite to a partner for a specific program. Resolves existing profiles by email (no duplicate profile created) or creates a pending invite the partner redeems on first sign-in. Optional customRate overrides the program default for this partner's membership.",
1455
+ schema: z.object({
1456
+ email: z.string().email().describe("Partner's email — used for matching + the invite email"),
1457
+ programId: z.string(),
1458
+ inviterName: z.string().optional().describe("Name shown in the invite email's 'from' field"),
1459
+ inviterMessage: z.string().optional().describe("Personal note from the merchant, shown in the invite email"),
1460
+ customRate: z.object({
1461
+ commissionStructure: z.object({
1462
+ type: z.enum(["cpa", "revshare"]),
1463
+ amountUsd: z.number().optional(),
1464
+ percentage: z.number().optional(),
1465
+ appliesTo: z.enum(["install", "purchase", "subscription"]),
1466
+ }),
1467
+ }).optional().describe("Override commission for this partner only (sticky)"),
1468
+ }),
1469
+ handler: async (args) => {
1470
+ const data = await apiPost("/api/mobile/affiliates/invites", args);
1471
+ return { content: [{ type: "text", text: `Invite sent.\n\n${JSON.stringify(data, null, 2)}` }] };
1472
+ },
1473
+ },
1474
+ list_pending_payouts: {
1475
+ description: "Every approved-but-unpaid commission this merchant owes, grouped into aging buckets: 0-30 / 30-60 / 60+ days. Returns totals per bucket + the effective autoPayoutPolicy + current merchant cap. Use before approving batches to see the backlog.",
1476
+ schema: z.object({}),
1477
+ handler: async () => {
1478
+ const data = await apiGet("/api/mobile/affiliates/payouts/pending");
1479
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1480
+ },
1481
+ },
1482
+ approve_payouts: {
1483
+ description: "Approve one or more pending payouts. Each approved conversion: transitions to 'approved', writes a HoldbackEntry splitting commission into available + held portions per tier policy (25% held for NEW, 15% TRUSTED, 5% VERIFIED), stamps approvedAt. Transfer fires later via the scheduled payout orchestrator (run-payouts cron).",
1484
+ schema: z.object({
1485
+ conversionIds: z.array(z.string()).describe("Conversion ids from list_pending_payouts"),
1486
+ }),
1487
+ handler: async (args) => {
1488
+ const data = await apiPost("/api/mobile/affiliates/payouts/approve", { conversionIds: args.conversionIds });
1489
+ const { approved, skipped, failed } = data;
1490
+ return { content: [{ type: "text", text: `Approved ${approved.length}, skipped ${skipped.length}, failed ${failed.length}.\n\n${JSON.stringify(data, null, 2)}` }] };
1491
+ },
1492
+ },
1493
+ reject_payouts: {
1494
+ description: "Reject pending payouts (e.g. suspicious patterns that don't warrant a FraudCase). Flips conversions to 'reversed'. Also opens a low-severity audit FraudCase per rejection so patterns surface if the same partner's payouts get rejected repeatedly.",
1495
+ schema: z.object({
1496
+ conversionIds: z.array(z.string()),
1497
+ reason: z.string().optional().describe("Shown in the audit case — e.g. 'Duplicate event'"),
1498
+ }),
1499
+ handler: async (args) => {
1500
+ const data = await apiPost("/api/mobile/affiliates/payouts/reject", args);
1501
+ return { content: [{ type: "text", text: `Rejected.\n\n${JSON.stringify(data, null, 2)}` }] };
1502
+ },
1503
+ },
1504
+ get_effective_fees: {
1505
+ description: "Show the merchant's current fee schedule for every fee kind (facilitation_payout, identity_verification, instant_payout). Returns bps + flatUsd + source (platform_default | merchant_override | hardcoded_fallback) + isCustom flag. Useful for building pricing disclosure UIs or answering 'what am I charged?'.",
1506
+ schema: z.object({}),
1507
+ handler: async () => {
1508
+ const data = await apiGet("/api/mobile/affiliates/fees");
1509
+ const lines = Object.entries(data.fees).map(([kind, f]) => {
1510
+ const parts = [];
1511
+ if (f.bps && f.bps > 0)
1512
+ parts.push(`${(f.bps / 100).toFixed(2)}%`);
1513
+ if (f.flatUsd && f.flatUsd > 0)
1514
+ parts.push(`$${f.flatUsd.toFixed(2)}`);
1515
+ const display = parts.join(" + ") || "$0";
1516
+ const tag = f.isCustom ? " (custom)" : "";
1517
+ return `• ${kind}: ${display}${tag} — source: ${f.source}`;
1518
+ }).join("\n");
1519
+ return { content: [{ type: "text", text: `Fees for ${data.projectKey}:\n\n${lines}\n\nFull data:\n${JSON.stringify(data, null, 2)}` }] };
1520
+ },
1521
+ },
1522
+ vouch_for_partner: {
1523
+ description: "Vouch for a partner — bumps their trust tier by one level (NEW→TRUSTED or TRUSTED→VERIFIED) for 60 days. REQUIRES indemnityAcknowledged=true: if the partner commits fraud during the vouched window, your merchant account is liable for any clawback the reserve can't cover. Fails if the partner is already VERIFIED.",
1524
+ schema: z.object({
1525
+ profileId: z.string(),
1526
+ indemnityAcknowledged: z.literal(true).describe("Must be exactly `true` — acknowledges merchant liability for fraud during vouch window"),
1527
+ reason: z.string().optional().describe("Audit note — e.g. 'Long-standing professional relationship'"),
1528
+ vouchDays: z.number().optional().describe("Custom window in days (default 60, max 90)"),
1529
+ }),
1530
+ handler: async (args) => {
1531
+ const data = await apiPost(`/api/mobile/affiliates/${args.profileId}/vouch`, args);
1532
+ return { content: [{ type: "text", text: `Vouch recorded.\n\n${JSON.stringify(data, null, 2)}` }] };
1533
+ },
1534
+ },
1535
+ // ─── Wave 68cb — Audience Activation MCP tools ───────────────────
1536
+ // Pair with the AudienceActivationWizard (Layer 2) to give agents
1537
+ // a way to wire up audience instrumentation without leaving the
1538
+ // editor. Both tools are PROJECT-SCOPED via the api-client's
1539
+ // active-project context — the merchant's CLI run pins which
1540
+ // workspace they're acting on.
1541
+ wire_audience_seed: {
1542
+ description: `${APEX} — Wire up the events and traits a campaign audience seed depends on. Use this when the merchant says "wire up the active customers audience" or after the AudienceActivationWizard's Screen 1 hands off to Cursor. Returns the per-event/trait code snippets, the file recommendations, and fires a test event with is_test=1 so the wizard's polling loop confirms the wiring landed end-to-end. Idempotent: calling twice does NOT double-instrument; the agent should still review existing code before adding the snippet.`,
1543
+ schema: z.object({
1544
+ seedId: z
1545
+ .string()
1546
+ .describe("Audience seed id, e.g. 'aud-seed-active-customers-90d'. Returned by the seed-campaign-defaults POST endpoint and surfaced in the wizard's URL."),
1547
+ framework: z
1548
+ .enum(["typescript", "snippet", "python", "ruby"])
1549
+ .optional()
1550
+ .describe("Code style to return. Defaults to 'typescript' (the SDK's canonical surface). Use 'snippet' for script-tag installs (Shopify, WordPress, Webflow). The wizard sets this from the detected framework."),
1551
+ fireTestEvent: z
1552
+ .boolean()
1553
+ .optional()
1554
+ .describe("When true (default), fires a test event with is_test=1 for each event the seed depends on. The wizard's poll loop sees these and advances. Set to false if the agent only wants to read the snippets without writing test data."),
1555
+ }),
1556
+ handler: async ({ seedId, framework = "typescript", fireTestEvent = true, }) => {
1557
+ const projectKey = getActiveProject() || process.env.APEX_PROJECT_KEY;
1558
+ if (!projectKey) {
1559
+ return {
1560
+ content: [
1561
+ {
1562
+ type: "text",
1563
+ text: "No active project. Call set_active_project first or set APEX_PROJECT_KEY.",
1564
+ },
1565
+ ],
1566
+ };
1567
+ }
1568
+ // Pull the seed's instrumentation requirements via the
1569
+ // dashboard API. Single source of truth: the dashboard
1570
+ // walks the seed's predicate the same way the
1571
+ // AudienceBuilder does.
1572
+ const data = await apiGet(`/api/audiences/seed-campaign-defaults/${encodeURIComponent(seedId)}/instrumentation?framework=${framework}`);
1573
+ // Optionally fire test events so the wizard polling loop
1574
+ // can flip into the "test received" state immediately.
1575
+ // We fire one per event with is_test=1 so they don't
1576
+ // pollute analytics.
1577
+ const firedTests = [];
1578
+ if (fireTestEvent && data.events.length > 0) {
1579
+ for (const ev of data.events) {
1580
+ try {
1581
+ await apiPost("/api/events", {
1582
+ type: "custom",
1583
+ visitorId: "mcp-agent-wire-test",
1584
+ data: {
1585
+ eventName: ev.name,
1586
+ endUserId: "mcp-agent-test-user",
1587
+ is_test: 1,
1588
+ source: "mcp_wire_audience_seed",
1589
+ },
1590
+ });
1591
+ firedTests.push(ev.name);
1592
+ }
1593
+ catch {
1594
+ // Best-effort — the agent gets the snippets even if
1595
+ // test firing fails; the wizard's manual fire
1596
+ // button is still available.
1597
+ }
1598
+ }
1599
+ }
1600
+ const snippetBlocks = [];
1601
+ for (const ev of data.events) {
1602
+ const props = ev.properties.length > 0
1603
+ ? `\nProperties (predicate cares about):\n${ev.properties
1604
+ .map((p) => ` - ${p.name} (${p.type}): ${p.description}`)
1605
+ .join("\n")}`
1606
+ : "";
1607
+ snippetBlocks.push(`### Event: ${ev.name}${props}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${ev.sdkExample}\n\`\`\``);
1608
+ }
1609
+ for (const tr of data.traits) {
1610
+ snippetBlocks.push(`### Trait: ${tr.fieldPath}\n${tr.description}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${tr.sdkExample}\n\`\`\``);
1611
+ }
1612
+ const summary = [
1613
+ `# Wiring up "${data.seedName}"`,
1614
+ ``,
1615
+ `Predicate: ${data.predicateSummary}`,
1616
+ ``,
1617
+ `Add the following ${data.events.length} event ${data.events.length === 1 ? "call" : "calls"} and ${data.traits.length} trait ${data.traits.length === 1 ? "set" : "sets"} to the merchant's codebase. Find the right files (checkout success handler, login flow, session start) and insert the snippet, replacing placeholder properties with the merchant's actual values.`,
1618
+ ``,
1619
+ ...snippetBlocks,
1620
+ ``,
1621
+ firedTests.length > 0
1622
+ ? `## Test events fired\nFired test events for: ${firedTests.join(", ")}. The wizard's polling loop will see these and advance. Confirm the merchant fires REAL events from production code paths before relying on the audience.`
1623
+ : `## Test events\nNo test events fired (per fireTestEvent=false). Run the wizard's "Send a test event" button manually after wiring.`,
1624
+ ``,
1625
+ `Project: ${projectKey}`,
1626
+ ].join("\n");
1627
+ return { content: [{ type: "text", text: summary }] };
1628
+ },
1629
+ },
1630
+ wire_audience: {
1631
+ description: `${APEX} — Wire up the events and traits ANY audience depends on, not just starter audiences. Use this for bespoke / merchant-built audiences (e.g. 'New sign-ups in the last 14 days', 'Stalled sign-ups — no connector in 12h'). Walks the audience's predicate server-side, classifies each referenced event (auto-fired by the Apex snippet vs documented in Apex's event taxonomy vs custom merchant event), returns per-event recommendations, and optionally fires test events (is_test=1) for non-auto-fired events so the wizard's polling loop confirms wiring landed.`,
1632
+ schema: z.object({
1633
+ audienceId: z
1634
+ .string()
1635
+ .describe("Audience id (the workspace's audience record). Use list_audiences first to find it. Bespoke audiences carry generated ids; seed-cloned audiences carry ids starting with `aud-seed-`."),
1636
+ framework: z
1637
+ .enum(["typescript", "snippet", "python", "ruby"])
1638
+ .optional()
1639
+ .describe("Code style to return. Defaults to 'typescript'. Use 'snippet' for script-tag installs (Shopify, WordPress, Webflow)."),
1640
+ fireTestEvent: z
1641
+ .boolean()
1642
+ .optional()
1643
+ .describe("When true (default), fires a test event with is_test=1 for each NON-AUTO-FIRED event the audience depends on. Auto-fired events (pageview, click, etc.) are skipped — they're already firing automatically and don't need a synthetic test."),
1644
+ }),
1645
+ handler: async ({ audienceId, framework = "typescript", fireTestEvent = true, }) => {
1646
+ const projectKey = getActiveProject() || process.env.APEX_PROJECT_KEY;
1647
+ if (!projectKey) {
1648
+ return {
1649
+ content: [
1650
+ {
1651
+ type: "text",
1652
+ text: "No active project. Call set_active_project first or set APEX_PROJECT_KEY.",
1653
+ },
1654
+ ],
1655
+ };
1656
+ }
1657
+ const data = await apiGet(`/api/audiences/${encodeURIComponent(audienceId)}/instrumentation?framework=${framework}`);
1658
+ // Fire test events for non-auto-fired events only. Auto-fired
1659
+ // events are already flowing — sending a synthetic one would
1660
+ // be misleading (and it'd fail because the merchant's snippet
1661
+ // is what ingests pageview, not our agent).
1662
+ const wirableEvents = data.events.filter((e) => e.category !== "auto-fired-web" &&
1663
+ e.category !== "auto-fired-mobile");
1664
+ const firedTests = [];
1665
+ const skippedAutoFired = data.events
1666
+ .filter((e) => e.category === "auto-fired-web" ||
1667
+ e.category === "auto-fired-mobile")
1668
+ .map((e) => e.name);
1669
+ if (fireTestEvent && wirableEvents.length > 0) {
1670
+ for (const ev of wirableEvents) {
1671
+ try {
1672
+ await apiPost("/api/events", {
1673
+ type: "custom",
1674
+ visitorId: "mcp-agent-wire-test",
1675
+ data: {
1676
+ eventName: ev.name,
1677
+ endUserId: "mcp-agent-test-user",
1678
+ is_test: 1,
1679
+ source: "mcp_wire_audience",
1680
+ },
1681
+ });
1682
+ firedTests.push(ev.name);
1683
+ }
1684
+ catch {
1685
+ // Best-effort.
1686
+ }
1687
+ }
1688
+ }
1689
+ const sections = [];
1690
+ sections.push(`# Wiring up "${data.audienceName}"`);
1691
+ if (data.audienceSummary) {
1692
+ sections.push(`\n${data.audienceSummary}`);
1693
+ }
1694
+ if (skippedAutoFired.length > 0) {
1695
+ sections.push(`\n## Already automatic (no code needed)\nThe Apex web snippet / mobile SDK fires these events automatically. Just confirm the snippet/SDK is installed:`);
1696
+ for (const name of skippedAutoFired) {
1697
+ sections.push(` - ${name}`);
1698
+ }
1699
+ }
1700
+ if (wirableEvents.length > 0 || data.traits.length > 0) {
1701
+ sections.push(`\n## Code to add to the merchant's codebase`);
1702
+ for (const ev of wirableEvents) {
1703
+ const props = ev.properties.length > 0
1704
+ ? `\nProperties (audience reads these): ${ev.properties.map((p) => p.name).join(", ")}`
1705
+ : "";
1706
+ sections.push(`\n### Event: ${ev.name} (${ev.category})\n${ev.caption}${props}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${ev.sdkExample}\n\`\`\``);
1707
+ }
1708
+ for (const tr of data.traits) {
1709
+ sections.push(`\n### Trait: ${tr.fieldPath}\n${tr.description}\n\n\`\`\`${framework === "typescript" ? "ts" : framework}\n${tr.sdkExample}\n\`\`\``);
1710
+ }
1711
+ }
1712
+ if (firedTests.length > 0) {
1713
+ sections.push(`\n## Test events fired\nFired test events (is_test=1) for: ${firedTests.join(", ")}. The wizard's polling loop will see these and advance. Confirm the merchant fires REAL events from production code paths before relying on the audience.`);
1714
+ }
1715
+ else if (wirableEvents.length === 0) {
1716
+ sections.push(`\n## Test events\nNothing to fire — every event is auto-fired by Apex's snippet/SDK.`);
1717
+ }
1718
+ else {
1719
+ sections.push(`\n## Test events\nNo test events fired (per fireTestEvent=false). Run the wizard's "Send a test event" button manually after wiring.`);
1720
+ }
1721
+ sections.push(`\nProject: ${projectKey}`);
1722
+ return { content: [{ type: "text", text: sections.join("\n") }] };
1723
+ },
1724
+ },
1725
+ audit_audience_readiness: {
1726
+ description: `${APEX} — Check whether an audience is wired up and matching users. Use this to verify your wire_audience_seed work succeeded, or to diagnose why a campaign is showing zero matches. Returns the audience predicate, current matching count, recency buckets, and which referenced events have arrived from the workspace.`,
1727
+ schema: z.object({
1728
+ audienceId: z
1729
+ .string()
1730
+ .describe("Audience id (the workspace's audience record, not the seed). Use list_audiences first to find it."),
1731
+ }),
1732
+ handler: async ({ audienceId }) => {
1733
+ const projectKey = getActiveProject() || process.env.APEX_PROJECT_KEY;
1734
+ if (!projectKey) {
1735
+ return {
1736
+ content: [
1737
+ {
1738
+ type: "text",
1739
+ text: "No active project. Call set_active_project first or set APEX_PROJECT_KEY.",
1740
+ },
1741
+ ],
1742
+ };
1743
+ }
1744
+ const [audience, preview] = await Promise.all([
1745
+ apiGet(`/api/audiences/${encodeURIComponent(audienceId)}`),
1746
+ apiGet(`/api/audiences/${encodeURIComponent(audienceId)}/preview`).catch(() => null),
1747
+ ]);
1748
+ const matchingLine = preview
1749
+ ? `${preview.matchingCount.toLocaleString()} user${preview.matchingCount === 1 ? "" : "s"} currently match`
1750
+ : "Couldn't compute live preview (audience may still resolve correctly at send time)";
1751
+ const recencyLines = preview
1752
+ ? [
1753
+ ` - Last 24h: ${preview.recencyBuckets.last_24h}`,
1754
+ ` - Last 7d: ${preview.recencyBuckets.last_7d}`,
1755
+ ` - Last 30d: ${preview.recencyBuckets.last_30d}`,
1756
+ ` - Older: ${preview.recencyBuckets.older}`,
1757
+ ].join("\n")
1758
+ : " (no recency data)";
1759
+ const summary = [
1760
+ `# Audience readiness: ${audience.name}`,
1761
+ audience.description ? `\n${audience.description}` : "",
1762
+ `\n## Match status\n${matchingLine}`,
1763
+ `\n## Recency buckets\n${recencyLines}`,
1764
+ `\n## Predicate\n\`\`\`json\n${JSON.stringify(audience.predicate, null, 2)}\n\`\`\``,
1765
+ preview && preview.matchingCount === 0
1766
+ ? `\n## Diagnosis\nZero matches. Most likely causes:\n 1. Events the predicate references aren't firing yet from the merchant's codebase. Run \`wire_audience_seed\` if this is a starter audience, or check the predicate's event names against your codebase.\n 2. The predicate's window (\`last_n_days\`, \`ever\`) is too narrow.\n 3. End-user identity isn't being stitched. Each \`apex.track()\` call must include \`endUserId\` in its data payload, or be preceded by an \`identify()\` call.\n`
1767
+ : "",
1768
+ `\nProject: ${projectKey}`,
1769
+ ].join("\n");
1770
+ return { content: [{ type: "text", text: summary }] };
1771
+ },
1772
+ },
1773
+ // ─── Journey Exit Semantics — catch-up tools ────────────────────────────
1774
+ //
1775
+ // Wraps the journey-management + exit-rules + cart-state API
1776
+ // surfaces shipped in `journey_exit_semantics_0b4fe496.plan.md`.
1777
+ // Agent-friendly arg names (snake_case) are mapped to the REST
1778
+ // routes inside each handler.
1779
+ list_journeys: {
1780
+ description: `${APEX} — List every adaptive journey in the active workspace. Returns id, name, status (draft/published/paused/archived), and journey type (lifecycle/campaign). Foundation for exit-rule audit loops + journey CRUD agent flows.`,
1781
+ schema: z.object({}),
1782
+ handler: async () => {
1783
+ const journeys = await apiGet("/api/journeys");
1784
+ return {
1785
+ content: [
1786
+ {
1787
+ type: "text",
1788
+ text: JSON.stringify(journeys.map((j) => ({
1789
+ id: j.id,
1790
+ name: j.name,
1791
+ status: j.status,
1792
+ journey_type: j.journeyType ?? null,
1793
+ version: j.version,
1794
+ exit_rule_count: (j.exitEvents ?? []).length,
1795
+ })), null, 2),
1796
+ },
1797
+ ],
1798
+ };
1799
+ },
1800
+ },
1801
+ get_journey: {
1802
+ description: `${APEX} — Read the full configuration for a single journey, including steps, goal event, attribution window, and exit rules. Use after list_journeys to inspect a journey's wiring before recommending edits.`,
1803
+ schema: z.object({
1804
+ journey_id: z.string().describe("Journey id returned from list_journeys"),
1805
+ }),
1806
+ handler: async ({ journey_id }) => {
1807
+ const journey = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}`);
1808
+ return {
1809
+ content: [
1810
+ {
1811
+ type: "text",
1812
+ text: JSON.stringify(journey, null, 2),
1813
+ },
1814
+ ],
1815
+ };
1816
+ },
1817
+ },
1818
+ list_journey_exits: {
1819
+ description: `${APEX} — Just the exit-rules block of one journey. Cheaper than get_journey for audit loops that only need to know "does this journey suppress purchase / unsubscribe?".`,
1820
+ schema: z.object({
1821
+ journey_id: z.string(),
1822
+ }),
1823
+ handler: async ({ journey_id }) => {
1824
+ const body = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`);
1825
+ return {
1826
+ content: [
1827
+ {
1828
+ type: "text",
1829
+ text: JSON.stringify(body.data ?? [], null, 2),
1830
+ },
1831
+ ],
1832
+ };
1833
+ },
1834
+ },
1835
+ add_journey_exit: {
1836
+ description: `${APEX} — Append a new exit rule to a journey's draft. Rule fires when the chosen trigger contract's event lands on a contact with an in-flight execution — Apex stops the execution and skips any pending sends. Re-publish required for the rule to take effect on live runs.`,
1837
+ schema: z.object({
1838
+ journey_id: z.string(),
1839
+ trigger_contract_id: z
1840
+ .string()
1841
+ .describe("Trigger-contract id, e.g. trig-in-app-purchase"),
1842
+ label: z.string().optional(),
1843
+ enabled: z.boolean().optional().default(true),
1844
+ }),
1845
+ handler: async ({ journey_id, trigger_contract_id, label, enabled, }) => {
1846
+ const body = await apiPost(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`, {
1847
+ triggerContractId: trigger_contract_id,
1848
+ ...(label !== undefined && { label }),
1849
+ enabled: enabled !== false,
1850
+ });
1851
+ return {
1852
+ content: [
1853
+ {
1854
+ type: "text",
1855
+ text: `Added exit rule ${body.data.id} → ${body.data.triggerContractId}. Publish the journey for it to apply to new executions.`,
1856
+ },
1857
+ ],
1858
+ };
1859
+ },
1860
+ },
1861
+ remove_journey_exit: {
1862
+ description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
1863
+ schema: z.object({
1864
+ journey_id: z.string(),
1865
+ rule_id: z.string(),
1866
+ }),
1867
+ handler: async ({ journey_id, rule_id, }) => {
1868
+ await apiDelete(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules/${encodeURIComponent(rule_id)}`);
1869
+ return {
1870
+ content: [
1871
+ {
1872
+ type: "text",
1873
+ text: `Removed exit rule ${rule_id} from journey ${journey_id}.`,
1874
+ },
1875
+ ],
1876
+ };
1877
+ },
1878
+ },
1879
+ get_contact_cart: {
1880
+ description: `${APEX} — Read the rolled-up cart state for a contact (Phase 2 of Journey Exit Semantics). Returns lines, itemCount, valueCents, and last-updated timestamp. Useful for "is this lead worth a follow-up?" agent loops.`,
1881
+ schema: z.object({
1882
+ contact_id: z.string(),
1883
+ }),
1884
+ handler: async ({ contact_id }) => {
1885
+ const contact = await apiGet(`/api/contacts/${encodeURIComponent(contact_id)}`);
1886
+ return {
1887
+ content: [
1888
+ {
1889
+ type: "text",
1890
+ text: JSON.stringify({
1891
+ contact_id: contact.id,
1892
+ email: contact.email ?? null,
1893
+ cart: contact.cart ?? null,
1894
+ }, null, 2),
1895
+ },
1896
+ ],
1897
+ };
1898
+ },
1899
+ },
1900
+ audit_journey_exits: {
1901
+ description: `${APEX} — Audit every published journey in the workspace for exit-rule hygiene. Flags marketing/lifecycle journeys (goalEventName=in_app_purchase) that don't have a purchase exit rule — the most common misconfiguration that produces "received the recovery push AFTER converting" complaints.`,
1902
+ schema: z.object({}),
1903
+ handler: async () => {
1904
+ const journeys = await apiGet("/api/journeys");
1905
+ const findings = [];
1906
+ for (const j of journeys) {
1907
+ if (j.status !== "published" && j.status !== "paused")
1908
+ continue;
1909
+ const isMarketing = j.journeyType === "campaign" || j.journeyType === "lifecycle";
1910
+ if (!isMarketing)
1911
+ continue;
1912
+ const goalLooksLikePurchase = j.goalEventName === "in_app_purchase" ||
1913
+ j.goalEventName === "purchase";
1914
+ if (!goalLooksLikePurchase)
1915
+ continue;
1916
+ const hasPurchaseExit = (j.exitEvents ?? []).some((r) => r.enabled !== false &&
1917
+ (r.triggerContractId.includes("purchase") ||
1918
+ (r.label ?? "").toLowerCase().includes("purchase")));
1919
+ if (!hasPurchaseExit) {
1920
+ findings.push({
1921
+ journey_id: j.id,
1922
+ name: j.name,
1923
+ recommendation: "Marketing journey with purchase goal but no purchase exit rule. Users who convert mid-journey will still receive later sends.",
1924
+ });
1925
+ }
1926
+ }
1927
+ const text = findings.length === 0
1928
+ ? "✓ All published marketing journeys with a purchase goal have a purchase exit rule wired."
1929
+ : `# Exit-rule audit — ${findings.length} journey${findings.length === 1 ? "" : "s"} need attention\n\n${findings.map((f) => `- **${f.name}** (${f.journey_id}): ${f.recommendation}`).join("\n")}\n\nFix with: \`add_journey_exit\` for each journey using a purchase trigger contract.`;
1930
+ return { content: [{ type: "text", text }] };
1931
+ },
1932
+ },
1933
+ get_setup_state: {
1934
+ description: `${APEX} — READ-ONLY. Returns the current Set up Apex state for the active workspace: which steps the merchant has marked complete or skipped, plus the live signal flags (snippet installed, mobile live, sender domain verified, etc.). Use to answer "where am I in onboarding?" or to surface "your next step is X" recommendations. WRITE tools (complete_setup_step, skip_setup_step) are deliberately NOT exposed pending a server-enforced confirmation nonce.`,
1935
+ schema: z.object({}),
1936
+ handler: async () => {
1937
+ // The active project key is encoded in the x-apex-project header
1938
+ // that apiGet sends automatically.
1939
+ const data = await apiGet("/api/setup-state");
1940
+ const { overrides, signal, workspaceKey } = data.data;
1941
+ const completedSteps = overrides
1942
+ .filter((o) => o.action === "complete")
1943
+ .map((o) => o.stepId);
1944
+ const skippedSteps = overrides
1945
+ .filter((o) => o.action === "skipped")
1946
+ .map((o) => o.stepId);
1947
+ const signalLines = Object.entries(signal)
1948
+ .filter(([, v]) => typeof v === "boolean")
1949
+ .map(([k, v]) => `- ${k}: ${v ? "✓" : "—"}`)
1950
+ .join("\n");
1951
+ const text = `# Set up Apex — workspace ${workspaceKey}
1952
+
1953
+ ## Live signal
1954
+ ${signalLines || "_no signal data yet_"}
1955
+
1956
+ ## Completed steps (${completedSteps.length})
1957
+ ${completedSteps.length ? completedSteps.map((s) => `- ${s}`).join("\n") : "_none_"}
1958
+
1959
+ ## Skipped steps (${skippedSteps.length})
1960
+ ${skippedSteps.length ? skippedSteps.map((s) => `- ${s}`).join("\n") : "_none_"}
1961
+
1962
+ _Suggest the next step the user should tackle based on what's incomplete in the signal._`;
1963
+ return { content: [{ type: "text", text }] };
1964
+ },
1965
+ },
1966
+ // ─── Data Sources + unified analytics ──────────────────────────────────
1967
+ list_data_sources: {
1968
+ 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.`,
1969
+ schema: z.object({
1970
+ includeArchived: z
1971
+ .boolean()
1972
+ .optional()
1973
+ .describe("Include archived sources. Defaults to false."),
1974
+ }),
1975
+ handler: async ({ includeArchived }) => {
1976
+ const proj = getActiveProject();
1977
+ const qs = includeArchived ? "?includeArchived=true" : "";
1978
+ const json = await apiGet(`/api/workspaces/${proj}/data-sources${qs}`);
1979
+ const sources = json.data ?? [];
1980
+ if (sources.length === 0) {
1981
+ return { content: [{ type: "text", text: "No data sources registered yet. They auto-register on the first event, or add one in Integrations." }] };
1982
+ }
1983
+ const lines = sources.map((s) => `- **${s.name}** (${s.kind}) — ${s.status}${s.lastSeenAt ? ` · last seen ${s.lastSeenAt}` : ""} · \`${s.id}\``);
1984
+ return { content: [{ type: "text", text: `# Data Sources (${sources.length})\n\n${lines.join("\n")}` }] };
1985
+ },
1986
+ },
1987
+ create_data_source: {
1988
+ 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.`,
1989
+ schema: z.object({
1990
+ kind: z
1991
+ .enum(["website", "ios", "android", "server", "pos", "kiosk", "extension", "other"])
1992
+ .describe("The producer kind."),
1993
+ name: z.string().describe("Display name, e.g. 'Marketing site' or 'Backend API'."),
1994
+ }),
1995
+ handler: async ({ kind, name }) => {
1996
+ const proj = getActiveProject();
1997
+ const json = await apiPost(`/api/workspaces/${proj}/data-sources`, { kind, name });
1998
+ const s = json.data;
1999
+ return { content: [{ type: "text", text: `✓ Created data source **${s.name}** (${s.kind}) — ${s.status} · \`${s.id}\`` }] };
2000
+ },
2001
+ },
2002
+ get_data_source_health: {
2003
+ 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?"`,
2004
+ schema: z.object({
2005
+ id: z.string().describe("The data source id (ds_...)."),
2006
+ }),
2007
+ handler: async ({ id }) => {
2008
+ const proj = getActiveProject();
2009
+ const json = await apiGet(`/api/workspaces/${proj}/data-sources/${encodeURIComponent(id)}/health`);
2010
+ const h = json.data;
2011
+ 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"}` }] };
2012
+ },
2013
+ },
2014
+ get_analytics_sources: {
2015
+ 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?".`,
2016
+ schema: z.object({
2017
+ days: z.number().optional().describe("Lookback window in days (default 30, max 365)."),
2018
+ }),
2019
+ handler: async ({ days }) => {
2020
+ const qs = days ? `?days=${days}` : "";
2021
+ const json = await apiGet(`/api/analytics/sources${qs}`);
2022
+ const d = json.data;
2023
+ const srcLines = d.sources.map((s) => `- ${s.name} (${s.kind}, ${s.status}): ${s.eventCount.toLocaleString()} events`);
2024
+ const platLines = d.platforms.map((p) => `- ${p.platform}: ${p.count.toLocaleString()}`);
2025
+ const hostLines = d.hostnames.slice(0, 10).map((h) => `- ${h.hostname}: ${h.count.toLocaleString()}`);
2026
+ return {
2027
+ 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_"}` }],
2028
+ };
2029
+ },
2030
+ },
2031
+ get_analytics_hostnames: {
2032
+ 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.`,
2033
+ schema: z.object({
2034
+ limit: z.number().optional().describe("Max hostnames to return (default 100)."),
2035
+ }),
2036
+ handler: async ({ limit }) => {
2037
+ const qs = limit ? `?limit=${limit}` : "";
2038
+ const json = await apiGet(`/api/analytics/hostnames${qs}`);
2039
+ const hosts = json.data ?? [];
2040
+ const lines = hosts.map((h) => `- ${h.hostname}: ${h.count.toLocaleString()}`);
2041
+ return { content: [{ type: "text", text: hosts.length ? `# Hostnames (${hosts.length})\n\n${lines.join("\n")}` : "No hostnames seen yet." }] };
2042
+ },
2043
+ },
1209
2044
  };
1210
2045
  //# sourceMappingURL=tools.js.map