@apex-inc/mcp-server 0.12.0 → 0.14.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,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
2
4
  import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
3
5
  import { resolveExperimentHypothesis } from "./experiment-copy.js";
4
6
  const APEX = "∧ Apex";
@@ -62,6 +64,83 @@ function appUrl(path) {
62
64
  function journeyLink(id) {
63
65
  return appUrl(`/dashboard/communications/journeys/${id}`);
64
66
  }
67
+ /**
68
+ * Detect a MOBILE experiment surface from repo signals (the MCP runs in the
69
+ * merchant's repo). Walks up a few dirs looking for capacitor.config.* or a
70
+ * Capacitor/RN dependency. Returns null when there's no mobile signal (web is
71
+ * the safe default — a website repo carries no special marker). QA 2026-06-18.
72
+ */
73
+ function detectSurfaceFromRepo() {
74
+ try {
75
+ let dir = process.cwd();
76
+ for (let i = 0; i < 4; i++) {
77
+ for (const f of [
78
+ "capacitor.config.ts",
79
+ "capacitor.config.js",
80
+ "capacitor.config.json",
81
+ "capacitor.config.mjs",
82
+ ]) {
83
+ if (existsSync(join(dir, f)))
84
+ return "mobile";
85
+ }
86
+ const pkgPath = join(dir, "package.json");
87
+ if (existsSync(pkgPath)) {
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
90
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
91
+ if (deps["@apex-inc/capacitor-plugin"] ||
92
+ deps["@capacitor/core"] ||
93
+ deps["react-native"]) {
94
+ return "mobile";
95
+ }
96
+ }
97
+ catch {
98
+ /* unreadable package.json — keep walking */
99
+ }
100
+ }
101
+ const parent = dirname(dir);
102
+ if (parent === dir)
103
+ break;
104
+ dir = parent;
105
+ }
106
+ }
107
+ catch {
108
+ /* fs not available — fall through */
109
+ }
110
+ return null;
111
+ }
112
+ /**
113
+ * Resolve the experiment surface. Explicit wins; otherwise detect from repo
114
+ * signals, then the workspace's registered data sources. Returns an `ambiguous`
115
+ * error only when both web and mobile data sources exist and there's no repo
116
+ * signal — in that case the agent must choose. (QA: stop defaulting to web.)
117
+ */
118
+ async function resolveCreateSurface(explicit) {
119
+ if (explicit)
120
+ return { surface: explicit };
121
+ const repo = detectSurfaceFromRepo();
122
+ if (repo)
123
+ return { surface: repo };
124
+ try {
125
+ const ws = getActiveWorkspace();
126
+ if (ws) {
127
+ const r = await apiGet(`/api/workspaces/${encodeURIComponent(ws)}/readiness`);
128
+ const sources = r.data?.sources ?? [];
129
+ const hasMobile = sources.some((s) => s.kind === "ios" || s.kind === "android");
130
+ const hasWeb = sources.some((s) => s.kind === "website");
131
+ if (hasMobile && !hasWeb)
132
+ return { surface: "mobile" };
133
+ if (hasWeb && !hasMobile)
134
+ return { surface: "web" };
135
+ if (hasMobile && hasWeb)
136
+ return { ambiguous: true };
137
+ }
138
+ }
139
+ catch {
140
+ /* readiness unavailable — fall back to web */
141
+ }
142
+ return { surface: "web" };
143
+ }
65
144
  /**
66
145
  * Tenant echo (council CISO): never report a mutation as successful for a record
67
146
  * outside the active workspace (guards a leaked/cross-workspace key).
@@ -337,7 +416,7 @@ export const toolDefinitions = {
337
416
  guardrailThreshold: z.number().optional().describe("Harm margin for the guardrail, RELATIVE (0.25 = flag a >25% rise). Defaults to 0.25."),
338
417
  predictionMagnitude: z.string().optional().describe("Your committed prediction of the effect, e.g. '10% lift'. Recorded with 'agent' provenance and used for calibration. Required before the experiment can be ACTIVATED."),
339
418
  mode: z.enum(["sdk", "snippet"]).optional().describe("Experiment mode: 'sdk' for code-level (default via MCP), 'snippet' for runtime DOM"),
340
- surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' (default) for a website, 'mobile' for a native / Capacitor / React-Native app. If the repo is a mobile app (has capacitor.config.* or @apex-inc/capacitor-plugin), set 'mobile' for ALL its experiments even ones resolved via useApexVariant in the web layer so they group under the app, not a website. This sets the dashboard data-source label (Website vs Mobile app)."),
419
+ surface: z.enum(["web", "mobile"]).optional().describe("The PROPERTY the experiment runs in (not the SDK used): 'web' for a website, 'mobile' for a native / Capacitor / React-Native app. OPTIONAL when omitted it is auto-detected from repo signals (capacitor.config.* / @apex-inc/capacitor-plugin mobile) and the workspace's registered data sources; pass it explicitly only to override or when both web and mobile sources exist. Sets the dashboard data-source label (Website vs Mobile app)."),
341
420
  dataSourceId: z.string().optional().describe("Optional explicit Data Source (property) id this experiment runs in, e.g. 'ds_website' or 'ds_ios'. Usually inferred from surface; pass it to bind to a specific registered source. Tenant-validated server-side."),
342
421
  randomizationUnit: z.enum(["device", "visitor", "person", "account", "org"]).optional().describe("What to bucket on. Default 'visitor' (per-device). Use 'person' to give one stitched user the same variant across their devices, or 'account'/'org' for B2B account-level tests where everyone in an account should see the same variant."),
343
422
  preview: z.boolean().optional().describe("If true, returns a preview without creating. Default: true."),
@@ -345,7 +424,23 @@ export const toolDefinitions = {
345
424
  handler: async (args) => {
346
425
  const split = args.trafficSplit ?? 50;
347
426
  const mode = args.mode ?? "sdk";
348
- const experimentSurface = args.surface ?? "web";
427
+ // Surface auto-detect (QA 2026-06-18): when the agent doesn't pass a
428
+ // surface, infer it from repo signals + registered data sources instead of
429
+ // silently defaulting to web (which mislabels mobile apps). Ambiguous →
430
+ // ask the agent to choose.
431
+ const surfaceResolution = await resolveCreateSurface(args.surface);
432
+ if ("ambiguous" in surfaceResolution) {
433
+ return {
434
+ content: [
435
+ {
436
+ type: "text",
437
+ text: `${APEX} This workspace has both web and mobile data sources — pass surface ("web" or "mobile") explicitly so the experiment is labeled and grouped correctly.`,
438
+ },
439
+ ],
440
+ isError: true,
441
+ };
442
+ }
443
+ const experimentSurface = surfaceResolution.surface;
349
444
  const isPreview = args.preview !== false;
350
445
  // p1-metric — resolve the primary metric. Defaults to the form_submit
351
446
  // conversion metric; an explicit primaryMetricEvent is validated against
@@ -2133,11 +2228,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2133
2228
  },
2134
2229
  },
2135
2230
  edit_communication: {
2136
- description: "Update a communication's subject, body, CTA, channels, or status. Pass the communication ID and the fields to update.",
2231
+ description: "Update a communication's subject, body, CTA, channels, or status. Pass the communication ID and the fields to update. For blank/builder comms, the headline/body/ctaLabel/ctaUrl slots are synced into the rendered email body — verify with preview_communication.",
2137
2232
  schema: z.object({
2138
2233
  communicationId: z.string().describe("The communication ID to update"),
2139
2234
  subject: z.string().optional().describe("New email subject line"),
2140
- slots: z.record(z.string()).optional().describe("Content slot overrides (e.g. headline, body, ctaLabel, ctaUrl)"),
2235
+ slots: z.record(z.string()).optional().describe("Content overrides: headline, body, ctaLabel, ctaUrl. These now sync into the rendered email body (not just metadata)."),
2141
2236
  channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
2142
2237
  status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
2143
2238
  }),
@@ -2159,13 +2254,31 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2159
2254
  },
2160
2255
  },
2161
2256
  preview_communication: {
2162
- description: "See the rendered email with your brand applied. Returns HTML that can be displayed or sent as a test.",
2257
+ description: "See the rendered communication with your brand applied. Returns the subject, the rendered plain-text body (so you can verify edits actually landed), and a browser preview link.",
2163
2258
  schema: z.object({
2164
2259
  communicationId: z.string().describe("The communication ID to preview"),
2165
2260
  }),
2166
2261
  handler: async (args) => {
2167
2262
  const data = await apiPost(`/api/communications/${args.communicationId}/preview`, {});
2168
- return { content: [{ type: "text", text: `Subject: ${data.subject}\n\nHTML preview generated (${data.html?.length || 0} chars). The email is ready to send.` }] };
2263
+ // Strip tags from subject (preview may annotate it with a slot span).
2264
+ const subject = (data.subject ?? "").replace(/<[^>]+>/g, "");
2265
+ const text = (data.text ?? "").trim();
2266
+ const link = appUrl(`/dashboard/communications/${args.communicationId}`);
2267
+ return {
2268
+ content: [
2269
+ {
2270
+ type: "text",
2271
+ text: [
2272
+ `Subject: ${subject || "(none)"}`,
2273
+ ``,
2274
+ `Rendered body:`,
2275
+ text ? text : "(empty — add content via edit_communication)",
2276
+ ``,
2277
+ `Open in the browser to see the full styled email: ${link}`,
2278
+ ].join("\n"),
2279
+ },
2280
+ ],
2281
+ };
2169
2282
  },
2170
2283
  },
2171
2284
  send_test_communication: {
@@ -2706,14 +2819,52 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2706
2819
  }
2707
2820
  },
2708
2821
  },
2822
+ list_trigger_contracts: {
2823
+ description: `${APEX} — List the workspace's trigger contracts (the event bindings you pass to set_journey_trigger / add_journey_exit). Returns each contract's id + the event it fires on. Call this before set_journey_trigger so you use a real id instead of guessing.`,
2824
+ schema: z.object({}),
2825
+ handler: async () => {
2826
+ const data = await apiGet("/api/journeys/trigger-contracts");
2827
+ const contracts = data.contracts ?? [];
2828
+ if (contracts.length === 0) {
2829
+ return { content: [{ type: "text", text: `${APEX} No trigger contracts registered yet. Creating a blank journey seeds the starter set.` }] };
2830
+ }
2831
+ const lines = contracts.map((c) => ` • ${c.id} → ${c.eventName ?? "?"}`);
2832
+ return {
2833
+ content: [
2834
+ {
2835
+ type: "text",
2836
+ text: [`${APEX} Trigger contracts (${contracts.length})`, ...lines, ``, `Pass one of these ids as triggerContractId to set_journey_trigger.`].join("\n"),
2837
+ },
2838
+ ],
2839
+ };
2840
+ },
2841
+ },
2709
2842
  set_journey_trigger: {
2710
- description: `${APEX} — Set the entry trigger (the event that enrolls subjects) on a draft journey's trigger step. Pass a triggerContractId (a blank journey seeds the starter contracts; get_journey shows the current value).`,
2843
+ 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).`,
2711
2844
  schema: z.object({
2712
2845
  journeyId: z.string(),
2713
- triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on."),
2846
+ triggerContractId: z.string().describe("The trigger contract id (event binding) to enroll on — see list_trigger_contracts."),
2714
2847
  }),
2715
2848
  handler: async ({ journeyId, triggerContractId }) => {
2716
2849
  try {
2850
+ // Validate the contract id up front so a typo doesn't silently set a
2851
+ // dead trigger that only fails at publish — enumerate valid options.
2852
+ const cdata = await apiGet("/api/journeys/trigger-contracts");
2853
+ const contracts = cdata.contracts ?? [];
2854
+ if (contracts.length > 0 && !contracts.some((c) => c.id === triggerContractId)) {
2855
+ const opts = contracts
2856
+ .map((c) => `${c.id}${c.eventName ? ` (${c.eventName})` : ""}`)
2857
+ .join(", ");
2858
+ return {
2859
+ content: [
2860
+ {
2861
+ type: "text",
2862
+ text: `${APEX} Unknown triggerContractId "${triggerContractId}". Valid options: ${opts}`,
2863
+ },
2864
+ ],
2865
+ isError: true,
2866
+ };
2867
+ }
2717
2868
  const j = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
2718
2869
  const steps = (j.steps ?? []).map((s) => s.type === "trigger" ? { ...s, trigger: { type: "event", triggerContractId } } : s);
2719
2870
  const updated = await apiPatch(`/api/journeys/${encodeURIComponent(journeyId)}`, { steps });
@@ -2734,7 +2885,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2734
2885
  durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
2735
2886
  commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
2736
2887
  commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
2737
- channels: z.array(z.enum(["email", "in_app", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email'])."),
2888
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email']). Use the SAME tokens as create_communication (email, in_app_push, mobile_push) so a comm's channels map cleanly onto the send step; web_push is send-only."),
2738
2889
  }),
2739
2890
  handler: async (args) => {
2740
2891
  try {
@@ -2767,6 +2918,98 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2767
2918
  }
2768
2919
  },
2769
2920
  },
2921
+ add_journey_branch: {
2922
+ description: `${APEX} — Insert a CONDITIONAL content branch before the exit: evaluate one condition and send a different communication to each side. Use this for true content branching (e.g. "cart value > $100 → VIP email, else standard email"). For the simpler "skip the next send if they already converted" case, use add_journey_exit instead. The condition is an attribute test (field_path + operator + value) or an event test (event_fired / event_not_fired within a window).`,
2923
+ schema: z.object({
2924
+ journeyId: z.string(),
2925
+ condition: z.object({
2926
+ kind: z.enum(["attribute", "event_fired", "event_not_fired"]),
2927
+ field_path: z.string().optional().describe("attribute only: dotted path, e.g. 'cart.valueCents' or 'plan'."),
2928
+ operator: z
2929
+ .enum(["equals", "not_equals", "greater_than", "less_than", "in", "exists", "not_exists"])
2930
+ .optional()
2931
+ .describe("attribute only."),
2932
+ value: z
2933
+ .union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number(), z.boolean()]))])
2934
+ .optional()
2935
+ .describe("attribute only: the comparison value (omit for exists/not_exists)."),
2936
+ event_name: z.string().optional().describe("event only: the canonical event, e.g. 'purchase'."),
2937
+ window_days: z.number().optional().describe("event only: look-back window in days (omit = ever)."),
2938
+ }),
2939
+ whenTrueCommId: z.string().describe("Communication to send when the condition matches."),
2940
+ whenFalseCommId: z.string().optional().describe("Communication to send when it doesn't match (omit to just continue to exit)."),
2941
+ channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("Channels for both send arms (default ['email'])."),
2942
+ }),
2943
+ handler: async (args) => {
2944
+ try {
2945
+ const c = args.condition;
2946
+ // Build the predicate leaf (AudiencePredicate is a bare leaf here).
2947
+ let predicate;
2948
+ if (c.kind === "attribute") {
2949
+ if (!c.field_path || !c.operator) {
2950
+ return { content: [{ type: "text", text: `${APEX} An attribute condition needs field_path and operator.` }], isError: true };
2951
+ }
2952
+ predicate = {
2953
+ kind: "attribute",
2954
+ fieldPath: c.field_path,
2955
+ operator: c.operator,
2956
+ ...(c.value !== undefined ? { value: c.value } : {}),
2957
+ };
2958
+ }
2959
+ else {
2960
+ if (!c.event_name) {
2961
+ return { content: [{ type: "text", text: `${APEX} An event condition needs event_name.` }], isError: true };
2962
+ }
2963
+ predicate = {
2964
+ kind: c.kind,
2965
+ eventName: c.event_name,
2966
+ window: c.window_days ? { type: "last_n_days", days: c.window_days } : { type: "ever" },
2967
+ };
2968
+ }
2969
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
2970
+ const steps = [...(j.steps ?? [])];
2971
+ const exit = steps.find((s) => s.type === "exit");
2972
+ if (!exit) {
2973
+ return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert a branch.` }], isError: true };
2974
+ }
2975
+ const exitId = exit.id;
2976
+ const channels = args.channels ?? ["email"];
2977
+ const mkId = () => `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
2978
+ const trueId = mkId();
2979
+ const trueStep = { id: trueId, type: "send", label: "Send (match)", commId: args.whenTrueCommId, commVersion: 1, channels, next: exitId };
2980
+ const newSteps = [trueStep];
2981
+ let defaultNext = exitId;
2982
+ if (args.whenFalseCommId) {
2983
+ const falseId = mkId();
2984
+ newSteps.push({ id: falseId, type: "send", label: "Send (no match)", commId: args.whenFalseCommId, commVersion: 1, channels, next: exitId });
2985
+ defaultNext = falseId;
2986
+ }
2987
+ const branchId = mkId();
2988
+ const branchStep = {
2989
+ id: branchId,
2990
+ type: "branch",
2991
+ label: "Branch",
2992
+ mode: "condition",
2993
+ conditions: [{ predicate, next: trueId }],
2994
+ defaultNext,
2995
+ };
2996
+ // Insert the branch before the exit: rewire whatever currently points
2997
+ // to the exit so the branch sits in the linear flow. (newSteps aren't in
2998
+ // `steps` yet, so this only matches the existing pre-exit step.)
2999
+ const pre = steps.find((s) => s.next === exitId);
3000
+ if (pre)
3001
+ pre.next = branchId;
3002
+ steps.push(branchStep, ...newSteps);
3003
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
3004
+ if (!tenantOk(updated.workspaceKey))
3005
+ return tenantMismatch();
3006
+ return { content: [{ type: "text", text: `${APEX} Added a conditional branch to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
3007
+ }
3008
+ catch (err) {
3009
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
3010
+ }
3011
+ },
3012
+ },
2770
3013
  publish_journey: {
2771
3014
  description: `${APEX} — Publish a draft journey so it runs on live trigger events. SAFETY: defaults to a dry-run (validation only) — pass confirmLive:true to actually publish to real customers. Email sends require a verified sender domain or publish is blocked.`,
2772
3015
  schema: z.object({
@@ -2838,10 +3081,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2838
3081
  get_journey: {
2839
3082
  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.`,
2840
3083
  schema: z.object({
2841
- journey_id: z.string().describe("Journey id returned from list_journeys"),
3084
+ journeyId: z.string().describe("Journey id returned from list_journeys."),
2842
3085
  }),
2843
- handler: async ({ journey_id }) => {
2844
- const journey = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}`);
3086
+ handler: async ({ journeyId }) => {
3087
+ const journey = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}`);
2845
3088
  return {
2846
3089
  content: [
2847
3090
  {
@@ -2855,10 +3098,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2855
3098
  list_journey_exits: {
2856
3099
  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?".`,
2857
3100
  schema: z.object({
2858
- journey_id: z.string(),
3101
+ journeyId: z.string(),
2859
3102
  }),
2860
- handler: async ({ journey_id }) => {
2861
- const body = await apiGet(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`);
3103
+ handler: async ({ journeyId }) => {
3104
+ const body = await apiGet(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`);
2862
3105
  return {
2863
3106
  content: [
2864
3107
  {
@@ -2872,16 +3115,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2872
3115
  add_journey_exit: {
2873
3116
  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.`,
2874
3117
  schema: z.object({
2875
- journey_id: z.string(),
2876
- trigger_contract_id: z
3118
+ journeyId: z.string(),
3119
+ triggerContractId: z
2877
3120
  .string()
2878
3121
  .describe("Trigger-contract id, e.g. trig-in-app-purchase"),
2879
3122
  label: z.string().optional(),
2880
3123
  enabled: z.boolean().optional().default(true),
2881
3124
  }),
2882
- handler: async ({ journey_id, trigger_contract_id, label, enabled, }) => {
2883
- const body = await apiPost(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules`, {
2884
- triggerContractId: trigger_contract_id,
3125
+ handler: async ({ journeyId, triggerContractId, label, enabled, }) => {
3126
+ const body = await apiPost(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules`, {
3127
+ triggerContractId,
2885
3128
  ...(label !== undefined && { label }),
2886
3129
  enabled: enabled !== false,
2887
3130
  });
@@ -2898,16 +3141,16 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2898
3141
  remove_journey_exit: {
2899
3142
  description: `${APEX} — Remove an exit rule from a journey's draft. Re-publish required for the change to apply to live runs.`,
2900
3143
  schema: z.object({
2901
- journey_id: z.string(),
2902
- rule_id: z.string(),
3144
+ journeyId: z.string(),
3145
+ ruleId: z.string(),
2903
3146
  }),
2904
- handler: async ({ journey_id, rule_id, }) => {
2905
- await apiDelete(`/api/journeys/${encodeURIComponent(journey_id)}/exit-rules/${encodeURIComponent(rule_id)}`);
3147
+ handler: async ({ journeyId, ruleId, }) => {
3148
+ await apiDelete(`/api/journeys/${encodeURIComponent(journeyId)}/exit-rules/${encodeURIComponent(ruleId)}`);
2906
3149
  return {
2907
3150
  content: [
2908
3151
  {
2909
3152
  type: "text",
2910
- text: `Removed exit rule ${rule_id} from journey ${journey_id}.`,
3153
+ text: `Removed exit rule ${ruleId} from journey ${journeyId}.`,
2911
3154
  },
2912
3155
  ],
2913
3156
  };