@apex-inc/mcp-server 0.23.3 → 0.24.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/api-client.d.ts +7 -0
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +16 -0
- package/dist/api-client.js.map +1 -1
- package/dist/tools.d.ts +206 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +240 -13
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-communications/SKILL.md +11 -0
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", "
|
|
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: "
|
|
671
|
-
key: "
|
|
672
|
-
label: "Variant
|
|
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: "
|
|
692
|
-
key: "
|
|
693
|
-
label: "Variant
|
|
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,
|
|
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", "
|
|
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.`,
|
|
@@ -1919,7 +1919,7 @@ export const toolDefinitions = {
|
|
|
1919
1919
|
}
|
|
1920
1920
|
lines.push(`3-5x is healthy; <1x loses money on every ${person.label}; >5x is likely underinvested.`);
|
|
1921
1921
|
if (ranked.some((c) => c.ltgpCac === null && c.ltvCac !== null)) {
|
|
1922
|
-
lines.push(`(Channels above marked LTV:CAC are measured on revenue. Send product.unit_cost on every purchased item and Apex reports LTGP:CAC for them too — the same read with cost of goods netted out. Cost is all-or-nothing per order: an order with an uncosted line contributes to LTV:CAC only, so a partial rollout never inflates gross profit.)`);
|
|
1922
|
+
lines.push(`(Channels above marked LTV:CAC are measured on revenue. Send product.unit_cost on every purchased item and Apex reports LTGP:CAC for them too — the same read with cost of goods netted out. unit_cost is PER UNIT and gets multiplied by quantity; if you only know the order's total cost, send cost at the order level instead. Cost is all-or-nothing per order: an order with an uncosted line contributes to LTV:CAC only, so a partial rollout never inflates gross profit.)`);
|
|
1923
1923
|
}
|
|
1924
1924
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1925
1925
|
},
|
|
@@ -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({
|
|
@@ -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({
|