@apex-inc/mcp-server 0.23.4 → 0.25.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,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
- import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
4
+ import { apiGet, apiPost, apiPatch, apiPut, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
5
5
  import { resolveExperimentHypothesis } from "./experiment-copy.js";
6
6
  import { controlGateGuidanceFor } from "./control-gate-guidance.js";
7
7
  import { getPersonLabel, lowerPerson } from "./person-label.js";
@@ -36,7 +36,7 @@ function daysRunningOf(exp) {
36
36
  const endMs = exp.completedAt ? new Date(exp.completedAt).getTime() : Date.now();
37
37
  return Math.max(0, Math.floor((endMs - startMs) / 86_400_000));
38
38
  }
39
- /** Find a unified variant by its key (e.g. "control", "variant_b"). */
39
+ /** Find a unified variant by its key (e.g. "control", "variant_a"). */
40
40
  function findVariant(exp, key) {
41
41
  return exp.variants?.find((v) => v.key === key);
42
42
  }
@@ -667,9 +667,9 @@ export const toolDefinitions = {
667
667
  payload: { content: args.controlContent },
668
668
  },
669
669
  {
670
- id: "v_b",
671
- key: "variant_b",
672
- label: "Variant B",
670
+ id: "v_a",
671
+ key: "variant_a",
672
+ label: "Variant A",
673
673
  surface: "mobile",
674
674
  createdAt: createdAtIso,
675
675
  description: "Modified content",
@@ -688,9 +688,9 @@ export const toolDefinitions = {
688
688
  changes: [],
689
689
  },
690
690
  {
691
- id: "v_b",
692
- key: "variant_b",
693
- label: "Variant B",
691
+ id: "v_a",
692
+ key: "variant_a",
693
+ label: "Variant A",
694
694
  surface: "web",
695
695
  mode,
696
696
  createdAt: createdAtIso,
@@ -741,7 +741,7 @@ export const toolDefinitions = {
741
741
  hypothesisId,
742
742
  predictionId: args.predictionId,
743
743
  primaryMetric,
744
- allocation: { strategy: "hash", weights: { control: controlW, variant_b: 1 - controlW } },
744
+ allocation: { strategy: "hash", weights: { control: controlW, variant_a: 1 - controlW } },
745
745
  secondaryMetrics: [],
746
746
  // Explicit EXTRA guardrail when the agent named one (orthogonal to the
747
747
  // goal). When omitted, the server auto-attaches the conversion-model
@@ -775,7 +775,7 @@ export const toolDefinitions = {
775
775
  let previewUrl = "";
776
776
  try {
777
777
  const vu = new URL(exp.targetUrl || args.targetUrl);
778
- vu.searchParams.set("_apex_preview", "variant_b");
778
+ vu.searchParams.set("_apex_preview", "variant_a");
779
779
  vu.searchParams.set("_apex_exp", exp.id);
780
780
  if (args.targetAnchor)
781
781
  vu.hash = args.targetAnchor;
@@ -812,7 +812,7 @@ export const toolDefinitions = {
812
812
  `3. Add: import { useApexVariant } from "@apex-inc/react"`,
813
813
  `4. In the component, add: const variant = useApexVariant("${exp.id}") // fires experiment_exposure automatically`,
814
814
  `5. Wrap the target content in a conditional (fall back to control on null/error):`,
815
- ` {variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
815
+ ` {variant === "variant_a" || variant === "variant_b" ? <VARIANT_CONTENT> : <CONTROL_CONTENT>}`,
816
816
  `6. Screenshots for the dashboard:`,
817
817
  ` - Public web page: Apex auto-captures on create — nothing to do.`,
818
818
  ` - localhost / auth-gated: capture BOTH arms, then call attach_experiment_asset({experimentId, variantKey, imageBase64}).`,
@@ -829,7 +829,7 @@ export const toolDefinitions = {
829
829
  `2. Install the plugin: npm i @apex-inc/capacitor-plugin`,
830
830
  `3. Add: import { Apex } from "@apex-inc/capacitor-plugin"`,
831
831
  `4. Resolve the variant: const { value: variant } = await Apex.getVariant({ experimentId: "${exp.id}" }) // fires experiment_exposure automatically`,
832
- `5. Render conditionally (fall back to control on null/error): variant === "variant_b" ? <VARIANT> : <CONTROL>`,
832
+ `5. Render conditionally (fall back to control on null/error): variant === "variant_a" || variant === "variant_b" ? <VARIANT> : <CONTROL>`,
833
833
  `6. Screenshot both arms — add ONE debug-gated line on the screen that renders the variant, keyed to the resolved variant so it fires once the arm is on-screen:`,
834
834
  ` useEffect(() => { if (variant) Apex.captureVariantScreenshot({ experimentId: "${exp.id}", variantKey: variant }); }, [variant]);`,
835
835
  ` It is debug-gated (init Apex with debug:true) — a no-op in production, so real users are never screenshotted.`,
@@ -2063,6 +2063,163 @@ export const toolDefinitions = {
2063
2063
  return { content: [{ type: "text", text: lines.join("\n") }] };
2064
2064
  },
2065
2065
  },
2066
+ list_product_costs: {
2067
+ description: `${APEX} — What each product costs, and which ones Apex has no cost for. Lists every SKU with a declared cost PLUS every SKU the merchant's orders have sold in the last 30 days, so an uncosted product is visible rather than merely absent. Apex reports gross profit and LTGP:CAC only where cost is known, so the uncosted rows are the gap worth closing. Two columns matter: "orders" is the per-unit cost the merchant's own purchase events reported (preferred — it tracks their real costs over time), "declared" is a standing cost set in Apex that fills orders reporting none. Use this before set_product_cost so you never overwrite a cost that is already right.`,
2068
+ schema: z.object({
2069
+ uncostedOnly: z
2070
+ .boolean()
2071
+ .optional()
2072
+ .describe("Only SKUs with no cost from either route — the actionable list."),
2073
+ limit: z.number().optional().describe("Max rows to show (default 25)."),
2074
+ }),
2075
+ handler: async ({ uncostedOnly, limit, }) => {
2076
+ const res = await apiGet("/api/products");
2077
+ const all = res.data?.products ?? [];
2078
+ if (all.length === 0) {
2079
+ return {
2080
+ content: [
2081
+ {
2082
+ type: "text",
2083
+ // Not "no products" — an empty list here means Apex has neither a
2084
+ // declared cost NOR an order it could name a product from, and
2085
+ // those are different problems with different fixes.
2086
+ text: `${APEX} No products on file. Apex lists a product once it either has a declared cost or appears on an order — so send purchase events with product_id on each line item (see get_apex_spec for "order_placed"), or connect Shopify to import variant costs.`,
2087
+ },
2088
+ ],
2089
+ };
2090
+ }
2091
+ const costKnown = (p) => p.costUsd !== undefined || p.unitCostUsd !== undefined;
2092
+ const rows = uncostedOnly ? all.filter((p) => !costKnown(p)) : all;
2093
+ const money = (n) => `$${n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
2094
+ const withCost = all.filter(costKnown).length;
2095
+ const lines = [
2096
+ `${APEX} Product cost — Apex knows the cost of ${withCost} of ${all.length} products`,
2097
+ "═".repeat(40),
2098
+ ...rows.slice(0, limit ?? 25).map((p) => {
2099
+ const observed = p.unitCostUsd !== undefined
2100
+ ? `orders ${money(p.unitCostUsd)}`
2101
+ : "orders: not reported";
2102
+ const declared = p.costUsd !== undefined
2103
+ ? `declared ${money(p.costUsd)}${p.costSource ? ` (${p.costSource})` : ""}${p.costEffectiveFrom ? ` from ${p.costEffectiveFrom.slice(0, 10)}` : ""}`
2104
+ : "declared: none";
2105
+ const sold = p.revenue !== undefined && p.revenue > 0
2106
+ ? ` · ${money(p.revenue)} in 30d`
2107
+ : "";
2108
+ return ` • ${p.name ?? p.itemKey} [${p.itemKey}]: ${observed} · ${declared}${sold}`;
2109
+ }),
2110
+ ];
2111
+ if (rows.length > (limit ?? 25)) {
2112
+ lines.push(` … and ${rows.length - (limit ?? 25)} more`);
2113
+ }
2114
+ const uncosted = all.filter((p) => !costKnown(p)).length;
2115
+ if (uncosted > 0) {
2116
+ lines.push("", `${uncosted} product${uncosted === 1 ? "" : "s"} have no cost. The durable fix is sending unit_cost (per unit) on each purchase line item, which keeps up as costs change. Use set_product_cost to declare a standing cost where the checkout can't report one, or to backfill history.`);
2117
+ }
2118
+ if (res.metadata?.observed?.truncated) {
2119
+ lines.push("", "(The 30-day sales scan hit its read cap, so this list is a lower bound — a SKU that sold only in the unread remainder is missing.)");
2120
+ }
2121
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2122
+ },
2123
+ },
2124
+ set_product_cost: {
2125
+ description: `${APEX} — Declare what one product costs per unit, so Apex can report gross profit on orders that didn't carry a cost of their own. PREFER instrumenting unit_cost on purchase line items instead where you can: an order carries the cost that was true when it happened, whereas a standing cost declared here is a snapshot that goes stale silently. Use this to backfill history, or for a SKU whose checkout can't report cost. NEVER invent or estimate a cost — a fabricated margin looks measured and is not, and the merchant has no way to tell downstream. Ask them for the number. Rules: the cost is PER UNIT (not a line total), it never overrides a cost an order reported for itself, and it never applies backward before effective_from.`,
2126
+ schema: z.object({
2127
+ item_key: z
2128
+ .string()
2129
+ .describe("The product identifier Apex knows this SKU by — the product_id or sku on the merchant's order lines. Get it from list_product_costs."),
2130
+ cost_usd: z
2131
+ .number()
2132
+ .describe("Cost PER UNIT in USD. A four-figure value on a low-priced item is usually cents typed as dollars; values above 1000000 are refused."),
2133
+ effective_from: z
2134
+ .string()
2135
+ .optional()
2136
+ .describe("ISO date the cost applies from. Defaults to NOW, which is deliberate — defaulting backward would restate the merchant's whole order history at today's supplier price. Pass an explicit date only when they know the older price."),
2137
+ name: z
2138
+ .string()
2139
+ .optional()
2140
+ .describe("Display name, if Apex has no name for this SKU yet."),
2141
+ }),
2142
+ handler: async ({ item_key, cost_usd, effective_from, name, }) => {
2143
+ const res = await apiPut(`/api/products/${encodeURIComponent(item_key)}/cost`, {
2144
+ cost_usd,
2145
+ ...(effective_from ? { effective_from } : {}),
2146
+ ...(name ? { name } : {}),
2147
+ });
2148
+ const d = res.data;
2149
+ if (d && !d.changed) {
2150
+ return {
2151
+ content: [
2152
+ {
2153
+ type: "text",
2154
+ text: `${APEX} ${item_key} already had that cost on file — nothing changed, no log entry written.`,
2155
+ },
2156
+ ],
2157
+ };
2158
+ }
2159
+ const from = d?.entry?.effectiveFrom?.slice(0, 10) ?? "now";
2160
+ return {
2161
+ content: [
2162
+ {
2163
+ type: "text",
2164
+ text: `${APEX} Set ${item_key} to $${cost_usd.toFixed(2)} per unit, applying from ${from} forward. Orders before that date, and orders that reported their own cost, are untouched. Check list_product_costs to see what's still uncosted.`,
2165
+ },
2166
+ ],
2167
+ };
2168
+ },
2169
+ },
2170
+ upload_product_catalog: {
2171
+ description: `${APEX} — Tell Apex what the merchant sells, in bulk, BEFORE the first sale. Every other way a product reaches Apex needs a sale to have happened already, so a merchant setting up pre-launch has a catalog Apex knows nothing about. Use this when you can read their catalog from somewhere — a products table, a Shopify/Stripe export, a CSV they hand you. NEVER invent, estimate, or derive a cost from a price: a fabricated margin looks measured and nobody downstream can tell. Ask them, or send only the fields you actually know. cost_usd is applied to orders that report no cost of their own, from effective_from forward. price_usd is reference only — revenue, LTV and gross profit always come from what orders actually report, so a price here can never distort them; it makes the catalog complete before launch and lets Apex flag it when real orders disagree with it. Re-running with the same data is safe. Max 500 products per call — page through a larger catalog. This does not replace instrumenting unit_cost on purchase line items, which stays the durable fix because an order then carries the cost that was true when it happened; a catalog cost is a snapshot that goes stale silently.`,
2172
+ schema: z.object({
2173
+ products: z
2174
+ .array(z.object({
2175
+ item_key: z
2176
+ .string()
2177
+ .describe("The identifier the merchant's ORDERS will send for this SKU — their product_id or sku. If this doesn't match what the order lines carry, Apex can't connect the two."),
2178
+ name: z.string().optional().describe("Display name."),
2179
+ cost_usd: z
2180
+ .number()
2181
+ .optional()
2182
+ .describe("What the product costs the MERCHANT, per unit. Omit rather than guess."),
2183
+ price_usd: z
2184
+ .number()
2185
+ .optional()
2186
+ .describe("What the merchant SELLS it for, per unit. Reference data — never used as revenue."),
2187
+ effective_from: z
2188
+ .string()
2189
+ .optional()
2190
+ .describe("ISO date cost_usd applies from. Defaults to now, which is deliberate — defaulting backward would restate order history at today's supplier price."),
2191
+ }))
2192
+ .describe("Up to 500 products. Each needs item_key plus at least one of name, cost_usd, price_usd."),
2193
+ }),
2194
+ handler: async ({ products, }) => {
2195
+ const res = await apiPost("/api/products", { products });
2196
+ const d = res.data;
2197
+ if (!d) {
2198
+ return {
2199
+ content: [
2200
+ { type: "text", text: `${APEX} Catalog upload returned no result.` },
2201
+ ],
2202
+ };
2203
+ }
2204
+ const parts = [
2205
+ `${APEX} Saved ${d.upserted} product${d.upserted === 1 ? "" : "s"} — ${d.costChanged} with a new cost, ${d.priceSet} with a price.`,
2206
+ ];
2207
+ if (d.rejected > 0) {
2208
+ // Named rather than counted: a rejected row is a line the merchant can
2209
+ // fix, and "12 rejected" sends them back through the whole file.
2210
+ const listed = (d.errors ?? [])
2211
+ .slice(0, 10)
2212
+ .map((e) => ` - ${e.item_key || "(no item_key)"}: ${e.error}`)
2213
+ .join("\n");
2214
+ parts.push(`${d.rejected} row${d.rejected === 1 ? "" : "s"} rejected:\n${listed}`);
2215
+ if ((d.errors?.length ?? 0) > 10) {
2216
+ parts.push(`…and ${(d.errors?.length ?? 0) - 10} more.`);
2217
+ }
2218
+ }
2219
+ parts.push(`Costs apply only to orders that report no cost of their own. The durable fix is still sending unit_cost on each purchase line item — run list_product_costs once orders start arriving to see what's still uncovered.`);
2220
+ return { content: [{ type: "text", text: parts.join("\n\n") }] };
2221
+ },
2222
+ },
2066
2223
  get_ecommerce_returns: {
2067
2224
  description: `${APEX} — E-commerce returns & refunds: refund rate (money back, order basis), return rate (goods back — separate metric, never summed), reason breakdown, refund cycle time, AOV, and repeat purchase. Refund data lands automatically from Stripe; return_requested / return_completed events power the goods-back story.`,
2068
2225
  schema: z.object({
@@ -2936,7 +3093,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
2936
3093
  },
2937
3094
  },
2938
3095
  generate_communications: {
2939
- description: "Create your communication program from recommendations. Pass catalog entry IDs to generate tenant communications with pre-written copy.",
3096
+ description: "Create your communication program from recommendations. Pass catalog entry IDs to generate tenant communications with pre-written copy. Each new letter wears the workspace default Theme (Title / Body / Button look is snapshotted; catalog copy is not rewritten to match). Preview with preview_communication to see theme roles vs block overrides.",
2940
3097
  schema: z.object({
2941
3098
  catalogEntryIds: z.array(z.string()).describe("IDs from the recommendation output to generate"),
2942
3099
  }),
@@ -3036,6 +3193,76 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3036
3193
  };
3037
3194
  },
3038
3195
  },
3196
+ list_communication_themes: {
3197
+ description: "List workspace communication Themes (the look: fonts, sizes, colors, Title/Body/Button roles). A Theme is not a Template — Templates are the job (event, slots, copy). Activate a Template with no extra theme step; new emails wear the workspace default theme. Apply writes drafts only; the merchant still publishes each email.",
3198
+ schema: z.object({}),
3199
+ handler: async () => {
3200
+ const json = await apiGet("/api/communications/themes");
3201
+ return {
3202
+ content: [
3203
+ {
3204
+ type: "text",
3205
+ text: JSON.stringify(json, null, 2),
3206
+ },
3207
+ ],
3208
+ };
3209
+ },
3210
+ },
3211
+ create_communication_theme: {
3212
+ description: "Create a communication Theme (the look). Clone an existing theme with clone_from_id, or pass style/roles. Does not assign other emails. Tell the merchant to publish emails after applying.",
3213
+ schema: z.object({
3214
+ name: z.string().min(1).max(60),
3215
+ clone_from_id: z.string().optional(),
3216
+ style: z.record(z.string(), z.unknown()).optional(),
3217
+ roles: z.record(z.string(), z.unknown()).optional(),
3218
+ }),
3219
+ handler: async (args) => {
3220
+ const data = await apiPost("/api/communications/themes", {
3221
+ name: args.name,
3222
+ cloneFromId: args.clone_from_id,
3223
+ style: args.style,
3224
+ roles: args.roles,
3225
+ });
3226
+ return {
3227
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
3228
+ };
3229
+ },
3230
+ },
3231
+ update_communication_theme: {
3232
+ description: "Update a communication Theme's name, style tokens, or Title/Body/Button roles. Does not rewrite other emails. Use apply_communication_theme to draft the look onto chosen emails. set_default makes new activations wear this theme.",
3233
+ schema: z.object({
3234
+ theme_id: z.string(),
3235
+ name: z.string().min(1).max(60).optional(),
3236
+ style: z.record(z.string(), z.unknown()).optional(),
3237
+ roles: z.record(z.string(), z.unknown()).optional(),
3238
+ set_default: z.boolean().optional(),
3239
+ }),
3240
+ handler: async (args) => {
3241
+ const data = await apiPatch(`/api/communications/themes/${encodeURIComponent(args.theme_id)}`, {
3242
+ name: args.name,
3243
+ style: args.style,
3244
+ roles: args.roles,
3245
+ setDefault: args.set_default,
3246
+ });
3247
+ return {
3248
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
3249
+ };
3250
+ },
3251
+ },
3252
+ apply_communication_theme: {
3253
+ description: "Apply a Theme's look onto chosen communication Templates. Default apply is draft — recipients keep the old look until the merchant publishes. apply=live uses the look on the next send. Omit comm_ids to target every template already on this theme. Never group emails by experiment catalog ids; a shop's order-updates series is the same object.",
3254
+ schema: z.object({
3255
+ theme_id: z.string(),
3256
+ comm_ids: z.array(z.string()).optional(),
3257
+ apply: z.enum(["draft", "live"]).optional(),
3258
+ }),
3259
+ handler: async (args) => {
3260
+ const data = await apiPost(`/api/communications/themes/${encodeURIComponent(args.theme_id)}/apply`, { commIds: args.comm_ids, apply: args.apply });
3261
+ return {
3262
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
3263
+ };
3264
+ },
3265
+ },
3039
3266
  edit_communication: {
3040
3267
  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.\n\nA communication's Control is protected when it won an experiment or a published journey is sending it. Editing it then requires `intent`: \"compete\" adds your edit as a variant and measures it against the current content (nothing changes for recipients yet); \"replace\" makes your edit what everyone receives and supersedes any prior win. WHEN IN DOUBT, USE \"compete\" — it is recoverable, \"replace\" discards a measured result permanently. The server returns 409 intent_required with both options if you omit it.",
3041
3268
  schema: z.object({
@@ -3161,7 +3388,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3161
3388
  },
3162
3389
  },
3163
3390
  preview_communication: {
3164
- 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.",
3391
+ description: "See the rendered communication with your brand and Theme applied. Returns the subject, the rendered plain-text body, Title/Body/Button roles, which blocks override the theme, and a browser preview link.",
3165
3392
  schema: z.object({
3166
3393
  communicationId: z.string().describe("The communication ID to preview"),
3167
3394
  }),
@@ -3171,6 +3398,21 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3171
3398
  const subject = (data.subject ?? "").replace(/<[^>]+>/g, "");
3172
3399
  const text = (data.text ?? "").trim();
3173
3400
  const link = appUrl(`/dashboard/communications/${args.communicationId}`);
3401
+ const look = data.themeLook;
3402
+ const roleLines = look?.roles
3403
+ ? Object.entries(look.roles).map(([role, style]) => {
3404
+ const bits = [
3405
+ style.type,
3406
+ style.headingLevel,
3407
+ style.align,
3408
+ style.width,
3409
+ ].filter(Boolean);
3410
+ return ` ${role}: ${bits.join(" / ") || "(default)"}`;
3411
+ })
3412
+ : [" (no role snapshot — look is whatever is baked on the blocks)"];
3413
+ const overrideLines = look?.overrides && look.overrides.length > 0
3414
+ ? look.overrides.map((row) => ` ${row.label}: ${row.keys.join(", ")}`)
3415
+ : [" (none)"];
3174
3416
  return {
3175
3417
  content: [
3176
3418
  {
@@ -3181,6 +3423,12 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3181
3423
  `Rendered body:`,
3182
3424
  text ? text : "(empty — add content via edit_communication)",
3183
3425
  ``,
3426
+ `Theme roles:`,
3427
+ ...roleLines,
3428
+ ``,
3429
+ `Block overrides:`,
3430
+ ...overrideLines,
3431
+ ``,
3184
3432
  `Open in the browser to see the full styled email: ${link}`,
3185
3433
  ].join("\n"),
3186
3434
  },
@@ -3188,6 +3436,24 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
3188
3436
  };
3189
3437
  },
3190
3438
  },
3439
+ add_communication_variant: {
3440
+ 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.",
3441
+ schema: z.object({
3442
+ communicationId: z.string().describe("The communication to add a variant to"),
3443
+ label: z.string().optional().describe("Optional column label (e.g. Variant A)"),
3444
+ }),
3445
+ handler: async (args) => {
3446
+ const data = await apiPost(`/api/communications/${args.communicationId}/variants`, { label: args.label });
3447
+ return {
3448
+ content: [
3449
+ {
3450
+ type: "text",
3451
+ text: `Variant added.\n\n${JSON.stringify(data, null, 2)}`,
3452
+ },
3453
+ ],
3454
+ };
3455
+ },
3456
+ },
3191
3457
  send_test_communication: {
3192
3458
  description: "Send a test email to yourself. The email will be sent to the logged-in user's email address.",
3193
3459
  schema: z.object({