@apex-inc/mcp-server 0.27.0 → 0.27.2

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
@@ -2,12 +2,13 @@ import { z } from "zod";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { apiGet, apiPost, apiPatch, apiPut, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
5
+ import { collectHosts, evaluateTargetUrl } from "./workspace-target-url.js";
5
6
  import { resolveExperimentHypothesis } from "./experiment-copy.js";
6
7
  import { controlGateGuidanceFor } from "./control-gate-guidance.js";
7
8
  import { getPersonLabel, lowerPerson } from "./person-label.js";
8
9
  import { notStartedExplanation, publishedExperimentLine, } from "./publish-communication-copy.js";
9
10
  import { activationLiveLine, shouldRefuseActivationWiring, } from "./activation-wiring.js";
10
- const APEX = "Apex";
11
+ const APEX = "Apex";
11
12
  /**
12
13
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
13
14
  *
@@ -57,6 +58,55 @@ function titleCaseEvent(event) {
57
58
  .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
58
59
  .join(" ");
59
60
  }
61
+ function activeWorkspaceSiteUrl() {
62
+ const ctx = getUserContext();
63
+ const key = getActiveWorkspace();
64
+ if (!ctx || !key)
65
+ return null;
66
+ for (const org of ctx.orgs) {
67
+ const match = org.workspaces.find((w) => w.workspaceKey === key);
68
+ if (match?.url)
69
+ return match.url;
70
+ }
71
+ return null;
72
+ }
73
+ async function assertTargetUrlForActiveWorkspace(targetUrl) {
74
+ const namedSites = [];
75
+ let workspaceUrl = activeWorkspaceSiteUrl();
76
+ let catalogUnreadable = false;
77
+ try {
78
+ const env = await apiGet("/api/workspace/environments");
79
+ namedSites.push(...(env.data.production_hosts ?? []), ...(env.data.beta_hosts ?? []));
80
+ for (const row of env.data.known_hosts ?? []) {
81
+ namedSites.push(typeof row === "string" ? row : row.hostname ?? "");
82
+ }
83
+ if (env.data.workspace_url)
84
+ workspaceUrl = env.data.workspace_url;
85
+ }
86
+ catch {
87
+ catalogUnreadable = true;
88
+ }
89
+ let analyticsHosts = [];
90
+ try {
91
+ const json = await apiGet("/api/analytics/hostnames?limit=100");
92
+ analyticsHosts = json.data ?? [];
93
+ }
94
+ catch {
95
+ /* analytics is a supplement, not the source of truth */
96
+ }
97
+ const allowed = collectHosts({
98
+ analyticsHosts,
99
+ namedSites,
100
+ workspaceUrl,
101
+ });
102
+ if (allowed.length === 0 && catalogUnreadable) {
103
+ return {
104
+ ok: false,
105
+ message: "Could not load this workspace's sites (list_workspace_environments failed). Name the site with set_workspace_environments before setting target_url. Do not guess a brand domain or a sibling workspace's site.",
106
+ };
107
+ }
108
+ return evaluateTargetUrl(targetUrl, allowed);
109
+ }
60
110
  /**
61
111
  * Query string for the pack report windows: relative (`days` / `months`)
62
112
  * or explicit `start` + `end` (YYYY-MM-DD, both required together —
@@ -440,7 +490,7 @@ export const toolDefinitions = {
440
490
  description: `${APEX} — Create an experiment record. Returns a structured recipe for the AI agent to implement. The agent makes the actual code changes using useApexVariant hook — Apex never generates code. Always call with preview=true first so the user sees a summary.`,
441
491
  schema: z.object({
442
492
  name: z.string().describe("Experiment name"),
443
- targetUrl: z.string().describe("URL of the page to test (include #anchor for section targeting)"),
493
+ targetUrl: z.string().describe("URL of the page to test on the ACTIVE workspace (include #anchor for section targeting). Must be a host this workspace has actually sent events from — never guess a brand domain or a sibling workspace's site."),
444
494
  controlContent: z.string().describe("The current content that the control shows"),
445
495
  variantContent: z.string().describe("The new content for the variant"),
446
496
  targetComponent: z.string().optional().describe("Component or file path where the change should be made"),
@@ -467,6 +517,13 @@ export const toolDefinitions = {
467
517
  preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
468
518
  }),
469
519
  handler: async (args) => {
520
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
521
+ if (!targetCheck.ok) {
522
+ return {
523
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
524
+ isError: true,
525
+ };
526
+ }
470
527
  const split = args.trafficSplit ?? 50;
471
528
  // MCP experiments are ALWAYS code-native (SDK). Client-side snippet /
472
529
  // runtime-DOM experiments are intentionally NOT offered here (founder
@@ -791,12 +848,16 @@ export const toolDefinitions = {
791
848
  `2. Install the hook package: npm i @apex-inc/react`,
792
849
  `3. Add: import { useApexVariant } from "@apex-inc/react"`,
793
850
  `4. In the component, add: const variant = useApexVariant("${exp.id}") // fires experiment_exposure automatically`,
851
+ ` Next.js App Router: pass the request search string so the first HTML matches the preview URL: useApexVariant("${exp.id}", { search }). Without it the server renders control and the arm only appears after hydrate.`,
794
852
  `5. Wrap the target content in a conditional (fall back to control on null/error):`,
795
853
  ` {variant === "variant_a" || variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
796
- `6. Screenshots for the dashboard:`,
797
- ` - Public web page: Apex auto-captures on create nothing to do.`,
798
- ` - localhost / auth-gated: capture BOTH arms, then call attach_experiment_asset({experimentId, variantKey, imageBase64}).`,
799
- `7. Show the user the diff and ask them to preview at: ${previewUrl}`,
854
+ `6. Screenshots for the dashboard — REQUIRED before you tell the user the draft is ready:`,
855
+ ` - This is an SDK experiment. The variant exists only in local code until it ships.`,
856
+ ` - Apex does NOT auto-capture a public URL on create (that page does not have the variant yet, and a guessed host shows the wrong site on both arms).`,
857
+ ` - Capture BOTH arms on localhost with ?_apex_preview=control|variant_a&_apex_exp=${exp.id}, then call attach_experiment_asset({experimentId, variantKey, imageBase64}) for each arm.`,
858
+ ` - After the code is deployed, recapture_experiment_screenshots refreshes the live host.`,
859
+ `7. Show the user the diff. Dashboard "View live" links are built from target_url (not a hardcoded host). Preview the variant at: ${previewUrl}`,
860
+ ` If the merchant is QA'ing on localhost, the dashboard rewrites Apex app hosts to this origin so the unpublished variant is visible.`,
800
861
  `8. After preview approval, commit and push`,
801
862
  `9. Call track_deployment with the experiment ID and commit SHA`,
802
863
  `10. Call activate_experiment (with the user's go-ahead). If the code isn't live yet, the`,
@@ -940,7 +1001,7 @@ export const toolDefinitions = {
940
1001
  },
941
1002
  },
942
1003
  recapture_experiment_screenshots: {
943
- description: `${APEX} — Re-run the hosted screenshot capture for a WEB experiment's arms (renders the target URL for control + variant and stores the images shown on the dashboard). Use when screenshots are missing or stale e.g. the page changed, or an earlier capture failed. Web experiments only (mobile arms capture on-device via the SDK).`,
1004
+ description: `${APEX} — Re-run the hosted screenshot capture for a WEB experiment's arms (renders the target URL for control + variant and stores the images shown on the dashboard). Use after the variant is deployed. For unpublished SDK / Cursor drafts, attach localhost shots with attach_experiment_asset instead hosted recapture of a public URL will be refused.`,
944
1005
  schema: z.object({
945
1006
  experimentId: z.string().describe("The experiment ID to re-capture screenshots for."),
946
1007
  }),
@@ -1624,6 +1685,8 @@ export const toolDefinitions = {
1624
1685
  name: z.string().optional().describe("Rename (cosmetic; editable anytime)."),
1625
1686
  surface: z.enum(["web", "mobile"]).optional().describe("Correct the surface — editable only on a draft with 0 exposures (web↔mobile)."),
1626
1687
  hypothesis: z.string().optional().describe("Edit the hypothesis — draft only (pre-registration)."),
1688
+ targetUrl: z.string().optional().describe("Retarget a DRAFT to a URL on the ACTIVE workspace. Same host rules as create_experiment."),
1689
+ predictionId: z.string().optional().describe("Attach a minted prediction id (from log_prediction) to this draft. Also stamps the prediction onto the experiment's hypothesis when that link is missing. Required before activation."),
1627
1690
  primaryMetricEvent: z.string().optional().describe("Change the primary metric's canonical event — draft only."),
1628
1691
  primaryMetricType: z.enum(["rate", "count", "revenue", "duration"]).optional(),
1629
1692
  guardrailEvent: z.string().optional().describe("Replace the guardrail with this canonical protective event — draft only."),
@@ -1631,6 +1694,15 @@ export const toolDefinitions = {
1631
1694
  runtimeDays: z.number().int().min(1).max(365).optional().describe("Set how long to run the experiment, in days (the decision window Apex force-concludes at). Draft only — part of the pre-registration; freezes at launch (fork to change it)."),
1632
1695
  }),
1633
1696
  handler: async (args) => {
1697
+ if (args.targetUrl) {
1698
+ const targetCheck = await assertTargetUrlForActiveWorkspace(args.targetUrl);
1699
+ if (!targetCheck.ok) {
1700
+ return {
1701
+ content: [{ type: "text", text: `${APEX} ${targetCheck.message}` }],
1702
+ isError: true,
1703
+ };
1704
+ }
1705
+ }
1634
1706
  const body = { id: args.experimentId };
1635
1707
  if (args.name !== undefined)
1636
1708
  body.name = args.name;
@@ -1638,6 +1710,10 @@ export const toolDefinitions = {
1638
1710
  body.surface = args.surface;
1639
1711
  if (args.hypothesis !== undefined)
1640
1712
  body.hypothesis = args.hypothesis;
1713
+ if (args.targetUrl !== undefined)
1714
+ body.targetUrl = args.targetUrl;
1715
+ if (args.predictionId !== undefined)
1716
+ body.predictionId = args.predictionId;
1641
1717
  // evaluationWindow patch — the API merges it, so setting one of
1642
1718
  // minPerVariant / durationDays never clobbers the other.
1643
1719
  if (args.minSamplePerArm !== undefined || args.runtimeDays !== undefined) {
@@ -2726,17 +2802,13 @@ export const toolDefinitions = {
2726
2802
 
2727
2803
  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.
2728
2804
 
2729
- The Apex Spec ships two peer registries:
2730
-
2731
- \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\`, \`ui_action\`, \`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\`). Named in-product controls (buttons, tabs, widget chrome) are \`ui_action\` with \`action\` + \`name\` + optional \`location\` — do not invent one event per button. SDK helper: \`trackUiAction({ action, name, location })\`.
2805
+ 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 })\`.
2732
2806
 
2733
- \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_selected\`, \`integration_connected\`, \`setup_step_continued\`, \`integration_sync_completed\`, \`integration_sync_failed\`, \`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.
2807
+ 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.
2734
2808
 
2735
- 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.
2736
-
2737
- 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 (the same signal the Set up Apex page and get_wiring_status read). They are flagged as agent-fired (source: "mcp" in the event data + the x-apex-source header), so dashboards can distinguish them from real app traffic — but they are NOT test-mode events; fire them deliberately.`,
2809
+ 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.`,
2738
2810
  schema: z.object({
2739
- 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."),
2811
+ 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."),
2740
2812
  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' })."),
2741
2813
  }),
2742
2814
  handler: async ({ event, properties }) => {
@@ -2773,7 +2845,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2773
2845
  send_server_event: {
2774
2846
  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.`,
2775
2847
  schema: z.object({
2776
- type: z.string().describe("Event type (e.g. 'purchase_completed', 'signup_completed', or any custom verb)"),
2848
+ type: z.string().describe("Event type (e.g. 'order_placed', 'user_signed_up', or any custom verb)"),
2777
2849
  visitorId: z.string().optional().describe("Visitor ID to stitch back to a web/mobile session (apex_vid)"),
2778
2850
  email: z.string().optional().describe("Email to stitch by identity when no visitorId is known"),
2779
2851
  data: z.record(z.unknown()).optional().describe("Event payload (value, currency, order_id, …)"),
@@ -3100,7 +3172,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3100
3172
  },
3101
3173
  },
3102
3174
  list_communications: {
3103
- description: "List the workspace's communication templates (email / inbox / web push / mobile push) with the fields you need to CHOOSE one — for a journey send step, a broadcast, or a transactional send. Returns per template: title, version, status, pipeline, channels, subject, a one-line body preview so you can disambiguate similar titles without opening each one, and controlState. Pipeline is 'transactional' (bypasses opt-out — needs a recipient-initiated trigger) or 'marketing' (consent-gated). Filter by pipeline, channel, or a search string. This is the programmatic equivalent of the dashboard's communication picker.\n\ncontrolState tells you what the Control currently IS, and therefore what editing it will cost: 'winner' (won an experiment, unedited — editing needs an explicit intent), 'winner_edited' (won, then changed since), 'in_experiment' (frozen, an experiment is measuring it right now), 'no_winner' (an experiment ran and settled without one), 'none' (never experimented on, edit freely).",
3175
+ description: "List the workspace's communication templates (email / inbox / web push / mobile push) with the fields you need to CHOOSE one — for a journey send step, a broadcast, or a transactional send. Returns per template: title, version, status, pipeline, channels, subject, a one-line body preview so you can disambiguate similar titles without opening each one, and controlState. Pipeline is 'transactional' (bypasses opt-out — needs a recipient-initiated trigger) or 'marketing' (consent-gated). Filter by pipeline, channel, or a search string. This is the programmatic equivalent of the dashboard's communication picker.\n\ncontrolState tells you what the Control currently IS, and therefore what editing it will cost: 'winner' (won an experiment, unedited — editing needs an explicit intent), 'winner_edited' (won, then changed since), 'in_experiment' (frozen, an experiment is measuring it right now), 'no_winner' (an experiment ran and settled without one), 'none' (never experimented on). A live letter with no champion is still editable; Publish asks replace vs compete. A champion or a running test: do not edit Control — add a variant, then compete.",
3104
3176
  schema: z.object({
3105
3177
  pipeline: z
3106
3178
  .enum(["transactional", "marketing"])
@@ -3467,7 +3539,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3467
3539
  },
3468
3540
  },
3469
3541
  add_communication_variant: {
3470
- description: "Add a content variant (Variant A, B, …) to a communication. Clones the current Control into a new column so you can run a content experiment. Does not change what recipients get until you publish. Theme look is inherited; do not set per-block align here.",
3542
+ description: "Add a content variant (Variant A, B, …) to a communication. Clones the current Control into a new column so you can run a content experiment. When the letter is a champion, this is how you author a challenger — do not edit Control. Does not change what recipients get until you publish. Theme look is inherited; do not set per-block align here.",
3471
3543
  schema: z.object({
3472
3544
  communicationId: z.string().describe("The communication to add a variant to"),
3473
3545
  label: z.string().optional().describe("Optional column label (e.g. Variant A)"),
@@ -3502,8 +3574,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3502
3574
  mergeData: z.record(z.string()).optional().describe("Template variables to merge (e.g. user_name, company)"),
3503
3575
  }),
3504
3576
  handler: async (args) => {
3505
- await apiPost(`/api/communications/${args.communicationId}/send`, {
3506
- recipientEmail: args.recipientEmail,
3577
+ // `/send` was removed. The composer test route is the one-off send:
3578
+ // workspace-admin, optional recipient override, mergeData substitution.
3579
+ await apiPost(`/api/communications/${args.communicationId}/test`, {
3580
+ recipient: args.recipientEmail,
3507
3581
  mergeData: args.mergeData,
3508
3582
  });
3509
3583
  return { content: [{ type: "text", text: `Communication sent to ${args.recipientEmail}.` }] };
@@ -4052,18 +4126,40 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4052
4126
  },
4053
4127
  },
4054
4128
  set_journey_trigger: {
4055
- description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass a triggerContractId discover valid IDs with list_trigger_contracts (an invalid id is rejected with the list of options).`,
4129
+ description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass event_name (the Apex Spec event, e.g. user_signed_up). A trig-* contract id is storage-only prefer the Spec name.`,
4056
4130
  schema: z.object({
4057
4131
  journeyId: z.string(),
4058
- triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on see list_trigger_contracts."),
4132
+ eventName: z.string().optional().describe("Apex Spec event name that enrolls subjects (e.g. user_signed_up). Preferred."),
4133
+ triggerContractId: z.string().optional().describe("Storage id from list_trigger_contracts. Prefer event_name."),
4059
4134
  }),
4060
- handler: async ({ journeyId, triggerContractId }) => {
4135
+ handler: async ({ journeyId, eventName, triggerContractId, }) => {
4061
4136
  try {
4062
- // Validate the contract id up front so a typo doesn't silently set a
4063
- // dead trigger that only fails at publish — enumerate valid options.
4064
4137
  const cdata = await apiGet("/api/journeys/trigger-contracts");
4065
4138
  const contracts = cdata.contracts ?? [];
4066
- if (contracts.length > 0 && !contracts.some((c) => c.id === triggerContractId)) {
4139
+ let resolvedId = triggerContractId;
4140
+ if (eventName) {
4141
+ const match = contracts.find((c) => c.eventName === eventName);
4142
+ if (match) {
4143
+ resolvedId = match.id;
4144
+ }
4145
+ else {
4146
+ const created = await apiPost("/api/journey-triggers", {
4147
+ eventName,
4148
+ fieldMappings: { end_user_id: "endUserId" },
4149
+ recipientInitiated: true,
4150
+ });
4151
+ resolvedId = created.id;
4152
+ }
4153
+ }
4154
+ if (!resolvedId) {
4155
+ return {
4156
+ content: [{ type: "text", text: `${APEX} Pass event_name (preferred) or triggerContractId.` }],
4157
+ isError: true,
4158
+ };
4159
+ }
4160
+ if (!eventName &&
4161
+ contracts.length > 0 &&
4162
+ !contracts.some((c) => c.id === resolvedId)) {
4067
4163
  const opts = contracts
4068
4164
  .map((c) => `${c.id}${c.eventName ? ` (${c.eventName})` : ""}`)
4069
4165
  .join(", ");
@@ -4071,18 +4167,19 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4071
4167
  content: [
4072
4168
  {
4073
4169
  type: "text",
4074
- text: `${APEX} Unknown triggerContractId "${triggerContractId}". Valid options: ${opts}`,
4170
+ text: `${APEX} Unknown triggerContractId "${resolvedId}". Valid options: ${opts}`,
4075
4171
  },
4076
4172
  ],
4077
4173
  isError: true,
4078
4174
  };
4079
4175
  }
4080
4176
  const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
4081
- const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
4177
+ const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId: resolvedId } } : s);
4082
4178
  const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
4083
4179
  if (!tenantOk(updated.workspaceKey))
4084
4180
  return tenantMismatch();
4085
- return { content: [{ type: "text", text: `${APEX} Trigger set to contract ${triggerContractId} on journey ${journeyId}. View it: ${journeyLink(journeyId)}` }] };
4181
+ const label = eventName ? `${eventName} (${resolvedId})` : resolvedId;
4182
+ return { content: [{ type: "text", text: `${APEX} Trigger set to ${label} on journey ${journeyId}. View it: ${journeyLink(journeyId)}` }] };
4086
4183
  }
4087
4184
  catch (err) {
4088
4185
  return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
@@ -4831,7 +4928,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
4831
4928
  apex_test_event: {
4832
4929
  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.`,
4833
4930
  schema: z.object({
4834
- eventName: z.string().describe("Event name, e.g. signup_completed"),
4931
+ eventName: z.string().describe("Event name, e.g. user_signed_up"),
4835
4932
  attributes: z.record(z.unknown()).optional().describe("Event attributes"),
4836
4933
  visitorId: z.string().optional().describe("Visitor id (default a test id)"),
4837
4934
  }),
@@ -5180,7 +5277,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5180
5277
  // owned-event matcher managed on /dashboard/conversions). A Target is an
5181
5278
  // aggregate KPI number to hit by a date. "Goal" is retired as a noun.
5182
5279
  list_conversions: {
5183
- description: `${APEX} — List the workspace's Conversions (the owned-event definitions of valued actions, e.g. "Purchase", "Signup"). Call this when the user asks what conversions are defined, or before creating an experiment/journey that needs an objective. The one flagged primary drives default experiment objectives, funnel metrics, and scoring.`,
5280
+ 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.`,
5184
5281
  schema: z.object({}),
5185
5282
  handler: async () => {
5186
5283
  const conversions = await apiGet("/api/conversion-goals");
@@ -5207,7 +5304,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5207
5304
  },
5208
5305
  },
5209
5306
  create_conversion: {
5210
- description: `${APEX} — Define a new Conversion (an owned-event definition of a valued action). Most conversions are a custom_event with an eventName (e.g. "purchase", "signup") matching an Apex Spec event you send via track. Set isPrimary to make it the default objective for experiments and the headline funnel metric.`,
5307
+ 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.`,
5211
5308
  schema: z.object({
5212
5309
  name: z.string().describe('Human label, e.g. "Purchase" or "Signup"'),
5213
5310
  type: z
@@ -5259,7 +5356,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5259
5356
  },
5260
5357
  },
5261
5358
  set_primary_conversion: {
5262
- description: `${APEX} — Mark a Conversion as the primary one. Experiments without an explicit objective default to the primary conversion, and the headline funnel + scoring use it. Pass the conversion id from list_conversions.`,
5359
+ 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.`,
5263
5360
  schema: z.object({
5264
5361
  conversionId: z.string().describe("The conversion id to promote (from list_conversions)."),
5265
5362
  }),