@apex-inc/mcp-server 0.22.2 → 0.23.1
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/control-gate-guidance.d.ts +62 -0
- package/dist/control-gate-guidance.d.ts.map +1 -0
- package/dist/control-gate-guidance.js +119 -0
- package/dist/control-gate-guidance.js.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/person-label.d.ts +32 -0
- package/dist/person-label.d.ts.map +1 -0
- package/dist/person-label.js +68 -0
- package/dist/person-label.js.map +1 -0
- package/dist/tools.d.ts +56 -4
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +196 -27
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/apex-communications/SKILL.md +63 -2
- package/skills/apex-experimentation/SKILL.md +28 -1
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
4
|
import { apiGet, apiPost, apiPatch, apiDelete, postWithIdempotency, setActiveWorkspace, getActiveWorkspace, setActiveOrg, getActiveOrg, getUserContext } from "./api-client.js";
|
|
5
5
|
import { resolveExperimentHypothesis } from "./experiment-copy.js";
|
|
6
|
+
import { controlGateGuidanceFor } from "./control-gate-guidance.js";
|
|
7
|
+
import { getPersonLabel, lowerPerson } from "./person-label.js";
|
|
6
8
|
const APEX = "∧ Apex";
|
|
7
9
|
/**
|
|
8
10
|
* MOBX-006 — stable synthetic visitor id for agent-fired events.
|
|
@@ -79,6 +81,28 @@ function appUrl(path) {
|
|
|
79
81
|
const base = process.env.APEX_URL || process.env.APEX_API_URL || "http://localhost:3001";
|
|
80
82
|
return `${base.replace(/\/$/, "")}${path}`;
|
|
81
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Why publishing a comm with variants didn't start an experiment.
|
|
86
|
+
*
|
|
87
|
+
* Each of these is a missing precondition the user can actually fix, so the
|
|
88
|
+
* agent is told what to do rather than that "nothing happened."
|
|
89
|
+
*/
|
|
90
|
+
function notStartedExplanation(reason) {
|
|
91
|
+
switch (reason) {
|
|
92
|
+
case "no_host":
|
|
93
|
+
return "The variants were published but no experiment started: no published journey sends this communication, so there's no traffic to measure. Add it to a journey and publish that journey.";
|
|
94
|
+
case "no_goal":
|
|
95
|
+
return "The variants were published but no experiment started: the journey sending this communication has no goal event, so there's nothing to optimize toward. Set the journey's goal event first.";
|
|
96
|
+
case "ambiguous_host":
|
|
97
|
+
return "The variants were published but no experiment started: more than one published journey sends this communication. Re-issue publish_communication with host_journey_id.";
|
|
98
|
+
case "conflict":
|
|
99
|
+
return "The variants were published but no experiment started: another experiment already holds this communication. End it, then publish again.";
|
|
100
|
+
case "no_variants":
|
|
101
|
+
return "";
|
|
102
|
+
default:
|
|
103
|
+
return "The variants were published but the experiment could not be started. Check the communication in the dashboard.";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
82
106
|
function journeyLink(id) {
|
|
83
107
|
return appUrl(`/dashboard/communications/journeys/${id}`);
|
|
84
108
|
}
|
|
@@ -1834,7 +1858,7 @@ export const toolDefinitions = {
|
|
|
1834
1858
|
},
|
|
1835
1859
|
},
|
|
1836
1860
|
get_channel_economics: {
|
|
1837
|
-
description: `${APEX} — "Where should my next dollar go?" Per acquisition channel (raw first-touch): gross
|
|
1861
|
+
description: `${APEX} — "Where should my next dollar go?" Per acquisition channel (raw first-touch): LTGP:CAC (lifetime gross profit over CAC) where the merchant reports product cost, LTV:CAC (lifetime revenue over CAC) everywhere else, plus payback months, CAC, and customers, split into Acquisition (paid) vs Leverage (owned/organic, no spend). Computed from real contacts + spend + revenue — metrics with missing inputs are shown as "—", never fabricated. Free intelligence; the verdict is never gated.`,
|
|
1838
1862
|
schema: z.object({
|
|
1839
1863
|
days: z
|
|
1840
1864
|
.union([z.number(), z.literal("all")])
|
|
@@ -1868,12 +1892,20 @@ export const toolDefinitions = {
|
|
|
1868
1892
|
})[slug] ?? slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
1869
1893
|
const money = (n) => n == null ? "—" : `$${Math.round(n).toLocaleString()}`;
|
|
1870
1894
|
const ratio = (r) => (r == null ? "—" : `${r.toFixed(2)}x`);
|
|
1871
|
-
|
|
1895
|
+
// Each channel is ranked on its best available multiple. Both are real
|
|
1896
|
+
// metrics — LTGP:CAC nets out cost of goods, LTV:CAC doesn't — so the
|
|
1897
|
+
// line names which one it printed rather than dropping the number.
|
|
1898
|
+
const best = (c) => c.ltgpCac ?? c.ltvCac;
|
|
1899
|
+
const metric = (c) => c.ltgpCac !== null ? "LTGP:CAC" : "LTV:CAC";
|
|
1900
|
+
const ranked = [...channels].sort((a, b) => (best(b) ?? -1) - (best(a) ?? -1));
|
|
1872
1901
|
const acquisition = ranked.filter((c) => c.spend !== null && c.spend > 0);
|
|
1873
1902
|
const leverage = ranked.filter((c) => c.spend === null || c.spend === 0);
|
|
1874
|
-
|
|
1903
|
+
// The merchant reads this back on a screen that says "Leads" or
|
|
1904
|
+
// "Clients" if that's their vertical — say the same word they do.
|
|
1905
|
+
const person = lowerPerson(await getPersonLabel());
|
|
1906
|
+
const fmt = (c) => ` • ${label(c.channel)}: ${ratio(best(c))} ${metric(c)} · payback ${c.paybackMonths != null ? `${c.paybackMonths}mo` : "—"} · CAC ${money(c.cac)} · ${c.customers.toLocaleString()} ${person.plural}`;
|
|
1875
1907
|
const lines = [
|
|
1876
|
-
`${APEX} Channel economics —
|
|
1908
|
+
`${APEX} Channel economics — LTGP:CAC where product cost is reported, LTV:CAC otherwise`,
|
|
1877
1909
|
"═".repeat(40),
|
|
1878
1910
|
];
|
|
1879
1911
|
if (res.data?.needsReferee) {
|
|
@@ -1885,11 +1917,9 @@ export const toolDefinitions = {
|
|
|
1885
1917
|
if (leverage.length > 0) {
|
|
1886
1918
|
lines.push("Leverage (owned / organic):", ...leverage.map(fmt), "");
|
|
1887
1919
|
}
|
|
1888
|
-
lines.push(
|
|
1889
|
-
if (
|
|
1890
|
-
lines.push(`(
|
|
1891
|
-
? `${Math.round(res.data.grossMargin * 100)}%`
|
|
1892
|
-
: ""} gross margin — set your real margin in workspace settings to sharpen this.)`);
|
|
1920
|
+
lines.push(`3-5x is healthy; <1x loses money on every ${person.label}; >5x is likely underinvested.`);
|
|
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 purchase events and Apex reports LTGP:CAC for them too — the same read with cost of goods netted out.)`);
|
|
1893
1923
|
}
|
|
1894
1924
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1895
1925
|
},
|
|
@@ -1996,7 +2026,7 @@ export const toolDefinitions = {
|
|
|
1996
2026
|
get_ecommerce_product_sales: {
|
|
1997
2027
|
description: `${APEX} — E-commerce product sales: top products by revenue with the per-product view → add-to-cart → purchase funnel and refund counts. Products need a product_id on commerce events; never-wired signals are reported as unwired, not zero.`,
|
|
1998
2028
|
schema: z.object({
|
|
1999
|
-
days: z.number().optional().describe("Trailing window in days (default 90, max
|
|
2029
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
|
|
2000
2030
|
start: z
|
|
2001
2031
|
.string()
|
|
2002
2032
|
.optional()
|
|
@@ -2036,7 +2066,7 @@ export const toolDefinitions = {
|
|
|
2036
2066
|
get_ecommerce_returns: {
|
|
2037
2067
|
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.`,
|
|
2038
2068
|
schema: z.object({
|
|
2039
|
-
days: z.number().optional().describe("Trailing window in days (default 90, max
|
|
2069
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
|
|
2040
2070
|
start: z
|
|
2041
2071
|
.string()
|
|
2042
2072
|
.optional()
|
|
@@ -2076,7 +2106,7 @@ export const toolDefinitions = {
|
|
|
2076
2106
|
get_marketplace_metrics: {
|
|
2077
2107
|
description: `${APEX} — Marketplace health across six pillars: liquidity (search→transaction, match rate, zero-result searches, unfulfilled demand), supply & demand balance, economics (GMV vs the take — fee_amount is the platform's revenue, GMV never inflates LTV), buyer retention, trust rates, and concentration risk. Sliceable by category / geo / price_band (low-volume slices are suppressed for privacy). Metrics whose events aren't wired come back as unwired hints, never fabricated zeros.`,
|
|
2078
2108
|
schema: z.object({
|
|
2079
|
-
days: z.number().optional().describe("Trailing window in days (default 90, max
|
|
2109
|
+
days: z.number().optional().describe("Trailing window in days (default 90, max 396 — the 13-month event log)."),
|
|
2080
2110
|
start: z
|
|
2081
2111
|
.string()
|
|
2082
2112
|
.optional()
|
|
@@ -2634,7 +2664,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2634
2664
|
},
|
|
2635
2665
|
},
|
|
2636
2666
|
identify_user: {
|
|
2637
|
-
description: `${APEX} — Identify a user by email for identity stitching. Works identically to the SDK's identify() and the snippet's apex.identify(). Links an anonymous visitor to a known email
|
|
2667
|
+
description: `${APEX} — Identify a user by email for identity stitching. Works identically to the SDK's identify() and the snippet's apex.identify(). Links an anonymous visitor to a known email. Pass avatarUrl (a canonical attribute) to give the Contact a profile photo that renders across Apex — the people list, detail page, and Live widget. The workspace's own word for a person (Customer / Lead / Client / User, from its vertical) is what its dashboard shows; use that word when reporting back.`,
|
|
2638
2668
|
schema: z.object({
|
|
2639
2669
|
email: z.string().describe("User email address"),
|
|
2640
2670
|
name: z.string().optional().describe("User name"),
|
|
@@ -2649,10 +2679,11 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2649
2679
|
workspaceKey: (process.env.APEX_WORKSPACE_KEY || process.env.APEX_PROJECT_KEY) || "default",
|
|
2650
2680
|
metadata: { name, company, avatar_url: avatarUrl, ...metadata, source: "mcp" },
|
|
2651
2681
|
});
|
|
2682
|
+
const person = lowerPerson(await getPersonLabel());
|
|
2652
2683
|
return {
|
|
2653
2684
|
content: [{
|
|
2654
2685
|
type: "text",
|
|
2655
|
-
text: `Identity stitched: ${email}${name ? ` (${name})` : ""}${company ? ` at ${company}` : ""}\nA
|
|
2686
|
+
text: `Identity stitched: ${email}${name ? ` (${name})` : ""}${company ? ` at ${company}` : ""}\nA ${person.label} record has been created/linked in Apex.`,
|
|
2656
2687
|
}],
|
|
2657
2688
|
};
|
|
2658
2689
|
},
|
|
@@ -2916,7 +2947,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2916
2947
|
},
|
|
2917
2948
|
},
|
|
2918
2949
|
list_communications: {
|
|
2919
|
-
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,
|
|
2950
|
+
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).",
|
|
2920
2951
|
schema: z.object({
|
|
2921
2952
|
pipeline: z
|
|
2922
2953
|
.enum(["transactional", "marketing"])
|
|
@@ -2960,6 +2991,7 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2960
2991
|
: ["email"],
|
|
2961
2992
|
subject: typeof content.subject === "string" ? content.subject : "",
|
|
2962
2993
|
bodyPreview: String(bodyPreview),
|
|
2994
|
+
controlState: typeof comm.controlState === "string" ? comm.controlState : "none",
|
|
2963
2995
|
triggerEventId: typeof comm.triggerEventId === "string"
|
|
2964
2996
|
? comm.triggerEventId
|
|
2965
2997
|
: "",
|
|
@@ -2988,7 +3020,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
2988
3020
|
const preview = r.bodyPreview
|
|
2989
3021
|
? `\n ${r.bodyPreview.slice(0, 120)}`
|
|
2990
3022
|
: "";
|
|
2991
|
-
|
|
3023
|
+
const control = r.controlState && r.controlState !== "none"
|
|
3024
|
+
? ` — control: ${r.controlState}`
|
|
3025
|
+
: "";
|
|
3026
|
+
return `• ${r.title} (v${r.version}) — ${r.pipeline} — ${r.channels.join("/")} — ${r.status}${control}\n subject: ${r.subject || "(none)"}${preview}\n id: ${r.id}`;
|
|
2992
3027
|
})
|
|
2993
3028
|
.join("\n\n");
|
|
2994
3029
|
return {
|
|
@@ -3002,13 +3037,21 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
3002
3037
|
},
|
|
3003
3038
|
},
|
|
3004
3039
|
edit_communication: {
|
|
3005
|
-
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.",
|
|
3040
|
+
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.",
|
|
3006
3041
|
schema: z.object({
|
|
3007
3042
|
communicationId: z.string().describe("The communication ID to update"),
|
|
3008
3043
|
subject: z.string().optional().describe("New email subject line"),
|
|
3009
3044
|
slots: z.record(z.string()).optional().describe("Content overrides: headline, body, ctaLabel, ctaUrl. These now sync into the rendered email body (not just metadata)."),
|
|
3010
3045
|
channels: z.array(z.string()).optional().describe("Channels: email, in_app_push, mobile_push"),
|
|
3011
3046
|
status: z.enum(["draft", "active", "paused"]).optional().describe("Communication status"),
|
|
3047
|
+
intent: z
|
|
3048
|
+
.enum(["compete", "replace"])
|
|
3049
|
+
.optional()
|
|
3050
|
+
.describe('How to apply the edit when the Control is protected. "compete" = add it as a variant and run it as an experiment against the current content (safe default; the current content keeps sending). "replace" = update what everyone receives, superseding any prior win. Required only when the comm won an experiment or is live in a published journey.'),
|
|
3051
|
+
confirmLiveExperiment: z
|
|
3052
|
+
.boolean()
|
|
3053
|
+
.optional()
|
|
3054
|
+
.describe("Edit a communication that is the treatment in a RUNNING experiment. This marks that experiment invalid and excludes its result from the belief graph. Only pass true when the user has explicitly accepted losing the experiment."),
|
|
3012
3055
|
}),
|
|
3013
3056
|
handler: async (args) => {
|
|
3014
3057
|
const body = {};
|
|
@@ -3016,6 +3059,10 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
3016
3059
|
body.status = args.status;
|
|
3017
3060
|
if (args.channels)
|
|
3018
3061
|
body.channels = args.channels;
|
|
3062
|
+
if (args.intent)
|
|
3063
|
+
body.intent = args.intent;
|
|
3064
|
+
if (args.confirmLiveExperiment)
|
|
3065
|
+
body.confirmLiveExperiment = true;
|
|
3019
3066
|
const content = {};
|
|
3020
3067
|
if (args.subject)
|
|
3021
3068
|
content.subject = args.subject;
|
|
@@ -3023,8 +3070,94 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
|
|
|
3023
3070
|
content.slots = args.slots;
|
|
3024
3071
|
if (Object.keys(content).length)
|
|
3025
3072
|
body.content = content;
|
|
3026
|
-
|
|
3027
|
-
|
|
3073
|
+
try {
|
|
3074
|
+
const data = await apiPatch(`/api/communications/${args.communicationId}`, body);
|
|
3075
|
+
// The comm may be protected without the edit being blocked (draft saves
|
|
3076
|
+
// defer the question to publish). Carry the server's advisory through
|
|
3077
|
+
// so the agent knows a decision is coming rather than meeting it later.
|
|
3078
|
+
const advisory = typeof data?.controlAdvisory === "string"
|
|
3079
|
+
? `\n\n${data.controlAdvisory}`
|
|
3080
|
+
: "";
|
|
3081
|
+
return { content: [{ type: "text", text: `Communication updated.${advisory}\n\n${JSON.stringify(data, null, 2)}` }] };
|
|
3082
|
+
}
|
|
3083
|
+
catch (err) {
|
|
3084
|
+
const guidance = controlGateGuidanceFor(err, {
|
|
3085
|
+
toolName: "edit_communication",
|
|
3086
|
+
communicationId: args.communicationId,
|
|
3087
|
+
});
|
|
3088
|
+
return {
|
|
3089
|
+
content: [
|
|
3090
|
+
{
|
|
3091
|
+
type: "text",
|
|
3092
|
+
text: guidance ?? `${APEX} ${err instanceof Error ? err.message : String(err)}`,
|
|
3093
|
+
},
|
|
3094
|
+
],
|
|
3095
|
+
isError: true,
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
},
|
|
3099
|
+
},
|
|
3100
|
+
publish_communication: {
|
|
3101
|
+
description: "Publish a communication's draft as the live version — this is what makes an edit reach recipients. Editing only saves a draft.\n\nIf the communication has variants, publishing STARTS the experiment that measures them against the control. That is the point of authoring a variant: a variant that is never published is measured by nothing.\n\nTwo conflicts need a decision rather than a retry. `ambiguous_experiment_host` means several published journeys send this communication, so Apex cannot tell where the experiment belongs — the candidates are returned; ask the user and re-issue with host_journey_id. `comm_already_in_experiment` means one is already running; end it first.",
|
|
3102
|
+
schema: z.object({
|
|
3103
|
+
communicationId: z.string().describe("The communication ID to publish"),
|
|
3104
|
+
intent: z
|
|
3105
|
+
.enum(["compete", "replace"])
|
|
3106
|
+
.optional()
|
|
3107
|
+
.describe('How to apply the draft when the Control is protected. "compete" = the current content keeps sending and your draft runs against it as an experiment (safe default). "replace" = your draft becomes what everyone receives, superseding any prior win. Required only when the comm won an experiment or is live in a published journey.'),
|
|
3108
|
+
confirmLiveExperiment: z
|
|
3109
|
+
.boolean()
|
|
3110
|
+
.optional()
|
|
3111
|
+
.describe("Publish over a communication that is the treatment in a RUNNING experiment. Marks that experiment invalid and excludes its result from the belief graph. Only pass true when the user has explicitly accepted losing the experiment."),
|
|
3112
|
+
hostJourneyId: z
|
|
3113
|
+
.string()
|
|
3114
|
+
.optional()
|
|
3115
|
+
.describe("Which published journey hosts the experiment. Only needed after a 409 ambiguous_experiment_host, using one of the returned candidate ids."),
|
|
3116
|
+
}),
|
|
3117
|
+
handler: async (args) => {
|
|
3118
|
+
const body = {};
|
|
3119
|
+
if (args.intent)
|
|
3120
|
+
body.intent = args.intent;
|
|
3121
|
+
if (args.confirmLiveExperiment)
|
|
3122
|
+
body.confirmLiveExperiment = true;
|
|
3123
|
+
if (args.hostJourneyId)
|
|
3124
|
+
body.hostJourneyId = args.hostJourneyId;
|
|
3125
|
+
try {
|
|
3126
|
+
const data = await apiPost(`/api/communications/${args.communicationId}/publish`, body);
|
|
3127
|
+
const lines = [];
|
|
3128
|
+
if (data.published === false) {
|
|
3129
|
+
lines.push("Nothing to publish — no unpublished changes.");
|
|
3130
|
+
}
|
|
3131
|
+
else {
|
|
3132
|
+
lines.push(`Published version ${data.version ?? "?"}.`);
|
|
3133
|
+
}
|
|
3134
|
+
if (data.repinned?.length) {
|
|
3135
|
+
lines.push(`${data.repinned.length} send step(s) moved to the new version.`);
|
|
3136
|
+
}
|
|
3137
|
+
if (data.experiment) {
|
|
3138
|
+
lines.push(`Experiment started: ${data.experiment.id}. Watch it at ${appUrl(`/dashboard/experiments/${data.experiment.id}`)}`);
|
|
3139
|
+
}
|
|
3140
|
+
else if (data.experimentNotStarted) {
|
|
3141
|
+
lines.push(notStartedExplanation(data.experimentNotStarted));
|
|
3142
|
+
}
|
|
3143
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
3144
|
+
}
|
|
3145
|
+
catch (err) {
|
|
3146
|
+
const guidance = controlGateGuidanceFor(err, {
|
|
3147
|
+
toolName: "publish_communication",
|
|
3148
|
+
communicationId: args.communicationId,
|
|
3149
|
+
});
|
|
3150
|
+
return {
|
|
3151
|
+
content: [
|
|
3152
|
+
{
|
|
3153
|
+
type: "text",
|
|
3154
|
+
text: guidance ??
|
|
3155
|
+
`${APEX} ${err instanceof Error ? err.message : String(err)}`,
|
|
3156
|
+
},
|
|
3157
|
+
],
|
|
3158
|
+
isError: true,
|
|
3159
|
+
};
|
|
3160
|
+
}
|
|
3028
3161
|
},
|
|
3029
3162
|
},
|
|
3030
3163
|
preview_communication: {
|
|
@@ -4238,6 +4371,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
4238
4371
|
}
|
|
4239
4372
|
const json = await apiGet(`/api/workspaces/${encodeURIComponent(workspaceKey)}/readiness`);
|
|
4240
4373
|
const d = json.data;
|
|
4374
|
+
const person = lowerPerson(await getPersonLabel());
|
|
4241
4375
|
const eventLines = d.events.map((e) => `- ${e.name}: ${e.tier} (${e.count.toLocaleString()} events, platforms: ${e.platforms.join("/") || "—"})${e.observedFields?.length ? ` — fields seen: ${e.observedFields.join(", ")}` : ""}`);
|
|
4242
4376
|
const traitLines = d.traits.map((t) => `- ${t.name}: ${t.present ? "arriving" : "not seen"}${t.inferredType ? ` (${t.inferredType})` : ""}`);
|
|
4243
4377
|
const sourceLines = d.sources.map((s) => `- ${s.name} (${s.kind}): ${s.status}${s.lastSeenAt ? ` — last event ${s.lastSeenAt}` : ""}`);
|
|
@@ -4249,7 +4383,9 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
4249
4383
|
];
|
|
4250
4384
|
const milestones = [
|
|
4251
4385
|
d.milestones.firstEventAt ? `First event: ${d.milestones.firstEventAt}` : "First event: not yet",
|
|
4252
|
-
d.milestones.firstIdentifiedAt
|
|
4386
|
+
d.milestones.firstIdentifiedAt
|
|
4387
|
+
? `First identified ${person.label}: ${d.milestones.firstIdentifiedAt}`
|
|
4388
|
+
: `First identified ${person.label}: not yet`,
|
|
4253
4389
|
];
|
|
4254
4390
|
return {
|
|
4255
4391
|
content: [{
|
|
@@ -4404,11 +4540,23 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
4404
4540
|
description: `${APEX} — One-shot health check of every connected integration (last sync, status). Use before adding a feature that depends on a connector.`,
|
|
4405
4541
|
schema: z.object({}),
|
|
4406
4542
|
handler: async () => {
|
|
4407
|
-
|
|
4543
|
+
// GET /api/integrations is the connector LIST. `/api/connectors/*` only
|
|
4544
|
+
// has per-type auth + callback routes, so the old path 404'd and this
|
|
4545
|
+
// tool reported "No connected integrations" for every workspace.
|
|
4546
|
+
const connectors = await apiGet("/api/integrations");
|
|
4408
4547
|
const list = Array.isArray(connectors) ? connectors : [];
|
|
4409
4548
|
const connected = list.filter((c) => c.isConnected || c.status === "connected");
|
|
4410
4549
|
if (connected.length === 0) {
|
|
4411
|
-
|
|
4550
|
+
// Say which of the two it is. `apiGet` throws on a failed call, so
|
|
4551
|
+
// reaching here means the list really came back and really was empty.
|
|
4552
|
+
return {
|
|
4553
|
+
content: [
|
|
4554
|
+
{
|
|
4555
|
+
type: "text",
|
|
4556
|
+
text: `No connected integrations (the workspace returned ${list.length} available connector${list.length === 1 ? "" : "s"}, none connected).`,
|
|
4557
|
+
},
|
|
4558
|
+
],
|
|
4559
|
+
};
|
|
4412
4560
|
}
|
|
4413
4561
|
const lines = connected.map((c) => `- ${c.name ?? c.type}: ${c.status ?? "connected"}${c.lastSyncAt ? ` (last sync ${c.lastSyncAt})` : ""}`);
|
|
4414
4562
|
return { content: [{ type: "text", text: `# Integration health\n${lines.join("\n")}` }] };
|
|
@@ -4436,20 +4584,35 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
4436
4584
|
}),
|
|
4437
4585
|
handler: async (args) => {
|
|
4438
4586
|
const apexAuthed = Boolean(getActiveWorkspace());
|
|
4587
|
+
/**
|
|
4588
|
+
* "unknown" is a distinct state from false. These checks used to collapse
|
|
4589
|
+
* a failed API call into "not installed", so a 404 (this tool called a
|
|
4590
|
+
* path that never existed until 2026-08-03), an expired key, and a
|
|
4591
|
+
* genuinely uninstalled GitHub App were indistinguishable — and the
|
|
4592
|
+
* agent would tell the developer to install an app they already had.
|
|
4593
|
+
*/
|
|
4439
4594
|
let githubAppInstalled = false;
|
|
4440
4595
|
let currentRepoWritable = false;
|
|
4596
|
+
const checkErrors = [];
|
|
4597
|
+
const note = (what, err) => checkErrors.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
|
4441
4598
|
try {
|
|
4442
|
-
const connectors = await apiGet("/api/
|
|
4599
|
+
const connectors = await apiGet("/api/integrations");
|
|
4443
4600
|
const list = Array.isArray(connectors) ? connectors : [];
|
|
4444
4601
|
githubAppInstalled = list.some((c) => c.type === "github" && (c.isConnected || c.status === "connected"));
|
|
4445
4602
|
}
|
|
4446
|
-
catch {
|
|
4447
|
-
|
|
4603
|
+
catch (err) {
|
|
4604
|
+
githubAppInstalled = "unknown";
|
|
4605
|
+
note("GitHub App check failed", err);
|
|
4606
|
+
}
|
|
4607
|
+
if (args.repo && githubAppInstalled !== false) {
|
|
4448
4608
|
try {
|
|
4449
4609
|
const auto = await apiGet("/api/graduation-pipeline/automation");
|
|
4450
4610
|
currentRepoWritable = (auto.data?.writeRepos ?? []).includes(args.repo);
|
|
4451
4611
|
}
|
|
4452
|
-
catch {
|
|
4612
|
+
catch (err) {
|
|
4613
|
+
currentRepoWritable = "unknown";
|
|
4614
|
+
note("Repo write opt-in check failed", err);
|
|
4615
|
+
}
|
|
4453
4616
|
}
|
|
4454
4617
|
const readiness = {
|
|
4455
4618
|
apexAuthed,
|
|
@@ -4457,7 +4620,13 @@ _Suggest the next step the user should tackle based on what's incomplete in the
|
|
|
4457
4620
|
currentRepoWritable,
|
|
4458
4621
|
// The agent verifies these locally (presence of @apex-inc/sdk + repo access).
|
|
4459
4622
|
sdkInstalled: "check-locally",
|
|
4460
|
-
currentRepoAuthorized: githubAppInstalled ? "check-locally"
|
|
4623
|
+
currentRepoAuthorized: githubAppInstalled === false ? false : "check-locally",
|
|
4624
|
+
...(checkErrors.length > 0
|
|
4625
|
+
? {
|
|
4626
|
+
checkErrors,
|
|
4627
|
+
guidance: "A check that failed is NOT a check that came back negative. Report the error to the developer and resolve it before telling them anything is missing or unconfigured.",
|
|
4628
|
+
}
|
|
4629
|
+
: {}),
|
|
4461
4630
|
};
|
|
4462
4631
|
return { content: [{ type: "text", text: JSON.stringify(readiness, null, 2) }] };
|
|
4463
4632
|
},
|