@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/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-margin-adjusted LTV:CAC, 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.`,
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
- const ranked = [...channels].sort((a, b) => (b.ltvCacRatio ?? -1) - (a.ltvCacRatio ?? -1));
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
- const fmt = (c) => ` • ${label(c.channel)}: ${ratio(c.ltvCacRatio)} LTV:CAC · payback ${c.paybackMonths != null ? `${c.paybackMonths}mo` : ""} · CAC ${money(c.cac)} · ${c.customers.toLocaleString()} customers`;
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 — gross-margin LTV:CAC`,
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("3-5x LTV:CAC is healthy; <1x loses margin on every customer; >5x is likely underinvested.");
1889
- if (res.data?.grossMarginFromDefault) {
1890
- lines.push(`(Using a default ${res.data.grossMargin != null
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 180)."),
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 180)."),
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 180)."),
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/lead. Pass avatarUrl (a canonical attribute) to give the Contact a profile photo that renders across Apex — Customers list, detail page, and Live Customers widget.`,
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 lead record has been created/linked in Apex.`,
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, and a one-line body preview so you can disambiguate similar titles without opening each one. 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.",
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
- return `• ${r.title} (v${r.version}) ${r.pipeline} ${r.channels.join("/")} — ${r.status}\n subject: ${r.subject || "(none)"}${preview}\n id: ${r.id}`;
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
- const data = await apiPatch(`/api/communications/${args.communicationId}`, body);
3027
- return { content: [{ type: "text", text: `Communication updated.\n\n${JSON.stringify(data, null, 2)}` }] };
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 ? `First identified customer: ${d.milestones.firstIdentifiedAt}` : "First identified customer: not yet",
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
- const connectors = await apiGet("/api/connectors");
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
- return { content: [{ type: "text", text: "No connected integrations." }] };
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/connectors");
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 { /* unauth or unreachable */ }
4447
- if (args.repo && githubAppInstalled) {
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 { /* ignore */ }
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" : false,
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
  },