@sentientui/mcp 0.23.0 → 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/AGENTS.md CHANGED
@@ -56,7 +56,7 @@ Every write tool changes what real visitors see or what the project accepts —
56
56
  | `get_funnel_report` | read | Step-by-step funnel drop-off |
57
57
  | `list_guardrail_events` | read | Every variant still paused by a guardrail; `days` narrows to recent pauses |
58
58
  | `get_persona_breakdown` | read | Cluster distribution + avg reliability per cluster (all retained sessions, not windowed) |
59
- | `get_stage_breakdown` | read | E-commerce funnel stages: stage mix (observed vs hinted), arrival channel x stage, raw holdout per context, versions per context with evidence (windowed, observational — nothing serves on stage yet) |
59
+ | `get_stage_breakdown` | read | E-commerce funnel stages: stage mix (observed vs hinted), arrival channel x stage, stage moves between visits, tested holdout lift per first-visit context and channel (the headline lift's always-valid test, all-time, alpha split over 23 series), versions per context with evidence (windowed, observational) |
60
60
  | `get_layout_stats` | read | Per-persona layout order + bandit pulls/reward (cumulative state, not windowed) |
61
61
  | `get_insights` | read | Ranked measured findings (tier + sample, low-sample flagged; `limit`, default 10) + (Growth) AI narrations, which are unmeasured |
62
62
  | `refresh_insights` | write | Async trigger — wait ~15s then call `get_insights`. Starter+ and an editor/admin role |
package/README.md CHANGED
@@ -107,7 +107,7 @@ A demo token is provisioned automatically — 10 calls/month, read-only (write t
107
107
  | `get_funnel_report` | read | Step-by-step funnel drop-off |
108
108
  | `list_guardrail_events` | read | Every variant currently paused by a guardrail (pass `days` to see only pauses from the last N days) |
109
109
  | `get_persona_breakdown` | read | Visitor clusters with session counts and reliability (all retained sessions) |
110
- | `get_stage_breakdown` | read | E-commerce funnel stages: stage mix, arrival channel x stage, holdout per context and versions per context (windowed, observational) |
110
+ | `get_stage_breakdown` | read | E-commerce funnel stages: stage mix, arrival channel x stage, stage moves between visits, tested holdout lift per first-visit context and channel (all-time), and versions per context (windowed, observational) |
111
111
  | `get_layout_stats` | read | Per-persona section order with bandit pulls and reward (cumulative state) |
112
112
  | `get_insights` | read | Ranked measured findings with their sample (low samples flagged), plus AI narrations marked unmeasured |
113
113
  | `refresh_insights` | write | Regenerate AI insights — needs Starter or higher and an editor/admin role (a paid model call); advisor recommendations are Growth+ |
@@ -1241,7 +1241,36 @@ function registerPersonaTools(server, client) {
1241
1241
  // src/tools/stages.ts
1242
1242
  import { z as z6 } from "zod";
1243
1243
  var pct = (v) => v == null ? "n/a (too few sessions)" : `${(v * 100).toFixed(1)}%`;
1244
- var side = z6.object({ sessions: z6.number(), conversions: z6.number(), conversionRate: z6.number().nullable() });
1244
+ var counts = z6.object({ visitors: z6.number(), conversions: z6.number() });
1245
+ var segmentLift = z6.object({
1246
+ key: z6.string(),
1247
+ kind: z6.string().describe("stage | arrival | channel"),
1248
+ evidence: z6.string().describe("collecting | awaiting_conversions | not_significant | significant_positive | significant_negative"),
1249
+ testStarted: z6.boolean().describe("False until the series has 100 visitors per side and 10 conversions (its test scale is then fixed)"),
1250
+ liftPct: z6.number().nullable().describe("Relative lift %, null while collecting / awaiting conversions"),
1251
+ diffInterval: z6.object({ lower: z6.number(), upper: z6.number() }).nullable().describe("Always-valid range on the absolute rate difference (fractions)"),
1252
+ pValue: z6.number().nullable(),
1253
+ rates: z6.object({ adapted: z6.number(), control: z6.number() }).nullable(),
1254
+ adapted: counts,
1255
+ control: counts,
1256
+ epochs: z6.number()
1257
+ });
1258
+ function liftWords(l, minPerArm) {
1259
+ const n = `adapted ${l.adapted.visitors} / control ${l.control.visitors} visitors`;
1260
+ switch (l.evidence) {
1261
+ case "collecting":
1262
+ return `early \u2014 ${n}; a number shows at ${minPerArm} per side`;
1263
+ case "awaiting_conversions":
1264
+ return `early \u2014 ${n}; waiting on control-group conversions`;
1265
+ case "not_significant":
1266
+ return `not proven yet${l.liftPct != null ? ` (${l.liftPct > 0 ? "+" : ""}${l.liftPct}%, could be chance)` : ""}${l.testStarted ? "" : " \u2014 test not started"} \u2014 ${n}`;
1267
+ case "significant_positive":
1268
+ case "significant_negative":
1269
+ return `tested: ${l.liftPct != null && l.liftPct > 0 ? "+" : ""}${l.liftPct}% conversion${l.evidence === "significant_negative" ? " (worse)" : ""}${l.diffInterval ? `, range ${(l.diffInterval.lower * 100).toFixed(2)} to ${(l.diffInterval.upper * 100).toFixed(2)} pts` : ""} \u2014 ${n}`;
1270
+ default:
1271
+ return n;
1272
+ }
1273
+ }
1245
1274
  function registerStageTools(server, client) {
1246
1275
  server.registerTool(
1247
1276
  "get_stage_breakdown",
@@ -1250,7 +1279,7 @@ function registerStageTools(server, client) {
1250
1279
  // Mirrors GET /projects/:id/stage-report. Says OBSERVATIONAL in the
1251
1280
  // description because nothing serves on stage yet: an agent must not
1252
1281
  // read a per-context arm result as "stage-based serving would win".
1253
- description: "E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, holdout vs treated conversion per context (raw rates, observational \u2014 no significance test), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages." + UNTRUSTED_FIELDS_NOTE,
1282
+ description: `E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, how visitors moved between stages from one visit to the next, the TESTED holdout lift per first-visit context and per first-visit arrival channel (the same always-valid test as the headline lift, all-time, alpha split over all 23 possible series; "collecting" and "awaiting_conversions" are early, not results), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages.` + UNTRUSTED_FIELDS_NOTE,
1254
1283
  inputSchema: __spreadValues({ projectId: projectIdSchema }, rangeInputSchema("30d")),
1255
1284
  outputSchema: {
1256
1285
  enabled: z6.boolean().describe("False when the project does not record funnel stages (settings.stage absent)"),
@@ -1263,11 +1292,29 @@ function registerStageTools(server, client) {
1263
1292
  contexts: z6.array(z6.object({
1264
1293
  context: z6.string(),
1265
1294
  kind: z6.string().describe("stage | arrival"),
1266
- sessions: z6.number(),
1267
- treated: side,
1268
- holdout: side,
1269
- rawLiftPct: z6.number().nullable().describe("Raw relative difference, %; null until both arms have 100 sessions. Observational.")
1295
+ sessions: z6.number()
1270
1296
  })),
1297
+ holdoutLift: z6.object({
1298
+ basis: z6.string().describe("'first_visit': visitors grouped by their first visit's context/channel"),
1299
+ allTime: z6.boolean().describe("True: the window does not apply to the lift"),
1300
+ goal: z6.string(),
1301
+ alpha: z6.number().describe("Per-series alpha after the correction"),
1302
+ family: z6.number(),
1303
+ minPerArm: z6.number(),
1304
+ units: z6.number(),
1305
+ unclassified: z6.number().describe("Exposed visitors with no first-visit record (exposed before recording began)"),
1306
+ contexts: z6.array(segmentLift),
1307
+ channels: z6.array(segmentLift)
1308
+ }).nullable().optional(),
1309
+ transitions: z6.object({
1310
+ pairs: z6.number().describe("Consecutive recorded visits in the window"),
1311
+ visitors: z6.number(),
1312
+ rows: z6.array(z6.object({
1313
+ from: z6.string(),
1314
+ nextVisits: z6.number(),
1315
+ to: z6.array(z6.object({ stage: z6.string(), visits: z6.number(), visitors: z6.number(), share: z6.number().describe("0-1") }))
1316
+ }))
1317
+ }).optional(),
1271
1318
  arms: z6.array(z6.object({
1272
1319
  slotId: z6.string(),
1273
1320
  slotName: z6.string(),
@@ -1285,6 +1332,7 @@ function registerStageTools(server, client) {
1285
1332
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
1286
1333
  },
1287
1334
  withApiErrorGuidance(async ({ projectId, range, from, to }) => {
1335
+ var _a;
1288
1336
  const id = encodeURIComponent(projectId);
1289
1337
  const r = await client.get(`/projects/${id}/stage-report${rangeQuery({ range, from, to })}`);
1290
1338
  const structuredContent = {
@@ -1296,6 +1344,8 @@ function registerStageTools(server, client) {
1296
1344
  channels: r.channels,
1297
1345
  arrivals: r.arrivals,
1298
1346
  contexts: r.contexts,
1347
+ holdoutLift: (_a = r.holdoutLift) != null ? _a : null,
1348
+ transitions: r.transitions,
1299
1349
  arms: r.arms,
1300
1350
  window: r.window
1301
1351
  };
@@ -1320,11 +1370,24 @@ function registerStageTools(server, client) {
1320
1370
  ...r.stages.map((s) => `- ${s.stage}: ${s.sessions} (${(s.share * 100).toFixed(1)}%)${s.hinted ? `, ${s.hinted} hinted` : ""}`),
1321
1371
  "",
1322
1372
  "Arrival channel x stage (conversion):",
1323
- ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`),
1324
- "",
1325
- "Holdout vs treated per context (raw rates, no significance test):",
1326
- ...r.contexts.filter((c) => c.sessions > 0).map((c) => `- ${c.context}: treated ${pct(c.treated.conversionRate)} (n=${c.treated.sessions}), holdout ${pct(c.holdout.conversionRate)} (n=${c.holdout.sessions})${c.rawLiftPct != null ? `, raw difference ${c.rawLiftPct > 0 ? "+" : ""}${c.rawLiftPct}%` : ""}`)
1373
+ ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`)
1327
1374
  ];
1375
+ const t = r.transitions;
1376
+ if (t && t.rows.length > 0) {
1377
+ lines.push("", `Stage moves between visits (${t.pairs} next visits from ${t.visitors} returning visitors in the window):`);
1378
+ for (const row of t.rows) {
1379
+ lines.push(`- from ${row.from} (${row.nextVisits} next visits): ${row.to.map((x) => `${x.stage} ${(x.share * 100).toFixed(0)}%`).join(", ")}`);
1380
+ }
1381
+ }
1382
+ const h = r.holdoutLift;
1383
+ if (h && (h.contexts.length > 0 || h.channels.length > 0)) {
1384
+ lines.push(
1385
+ "",
1386
+ `Holdout lift by first-visit context and channel (tested, all-time \u2014 the window does not apply; alpha ${h.alpha.toPrecision(2)} per series over ${h.family}; ${h.unclassified} of ${h.units} visitors predate recording and are not in any row):`,
1387
+ ...h.contexts.map((l) => `- context ${l.key}: ${liftWords(l, h.minPerArm)}`),
1388
+ ...h.channels.map((l) => `- channel ${l.key}: ${liftWords(l, h.minPerArm)}`)
1389
+ );
1390
+ }
1328
1391
  if (r.arms.length > 0) {
1329
1392
  lines.push("", "Versions per context (what stage-based serving would have to beat):");
1330
1393
  for (const a of r.arms) {
@@ -2629,7 +2692,8 @@ and then, in the same tag after the ';', runs the inline pre-paint script; then
2629
2692
  loader. The pre-paint script reads window.sentient when it runs, so sharing the tag is safe. It
2630
2693
  is optional but recommended \u2014 the loader is deferred, so on a return visit the page can paint
2631
2694
  its original state before the loader runs; the inline script applies the last served decision
2632
- from the visitor's own device first, with no network call and no cloak. Get the exact bytes from
2695
+ from the visitor's own device first, with no network call and no page-wide cloak (it briefly hides
2696
+ only published components, and only for a visitor who just clicked an ad or email). Get the exact bytes from
2633
2697
  the dashboard's Install page, or from renderSnippetInstall({ config }) in
2634
2698
  '@sentientui/snippet/install' (renderSnippetPrePaintScript() for the script alone).
2635
2699
 
@@ -3126,7 +3190,7 @@ function registerCellTools(server, client) {
3126
3190
  }
3127
3191
 
3128
3192
  // src/server.ts
3129
- var PKG_VERSION = true ? "0.23.0" : "0.0.0-dev";
3193
+ var PKG_VERSION = true ? "0.24.0" : "0.0.0-dev";
3130
3194
  function createMcpServer(client) {
3131
3195
  const server = new McpServer(
3132
3196
  {
package/dist/index.cjs CHANGED
@@ -1245,7 +1245,36 @@ function registerPersonaTools(server, client) {
1245
1245
  // src/tools/stages.ts
1246
1246
  var import_zod6 = require("zod");
1247
1247
  var pct = (v) => v == null ? "n/a (too few sessions)" : `${(v * 100).toFixed(1)}%`;
1248
- var side = import_zod6.z.object({ sessions: import_zod6.z.number(), conversions: import_zod6.z.number(), conversionRate: import_zod6.z.number().nullable() });
1248
+ var counts = import_zod6.z.object({ visitors: import_zod6.z.number(), conversions: import_zod6.z.number() });
1249
+ var segmentLift = import_zod6.z.object({
1250
+ key: import_zod6.z.string(),
1251
+ kind: import_zod6.z.string().describe("stage | arrival | channel"),
1252
+ evidence: import_zod6.z.string().describe("collecting | awaiting_conversions | not_significant | significant_positive | significant_negative"),
1253
+ testStarted: import_zod6.z.boolean().describe("False until the series has 100 visitors per side and 10 conversions (its test scale is then fixed)"),
1254
+ liftPct: import_zod6.z.number().nullable().describe("Relative lift %, null while collecting / awaiting conversions"),
1255
+ diffInterval: import_zod6.z.object({ lower: import_zod6.z.number(), upper: import_zod6.z.number() }).nullable().describe("Always-valid range on the absolute rate difference (fractions)"),
1256
+ pValue: import_zod6.z.number().nullable(),
1257
+ rates: import_zod6.z.object({ adapted: import_zod6.z.number(), control: import_zod6.z.number() }).nullable(),
1258
+ adapted: counts,
1259
+ control: counts,
1260
+ epochs: import_zod6.z.number()
1261
+ });
1262
+ function liftWords(l, minPerArm) {
1263
+ const n = `adapted ${l.adapted.visitors} / control ${l.control.visitors} visitors`;
1264
+ switch (l.evidence) {
1265
+ case "collecting":
1266
+ return `early \u2014 ${n}; a number shows at ${minPerArm} per side`;
1267
+ case "awaiting_conversions":
1268
+ return `early \u2014 ${n}; waiting on control-group conversions`;
1269
+ case "not_significant":
1270
+ return `not proven yet${l.liftPct != null ? ` (${l.liftPct > 0 ? "+" : ""}${l.liftPct}%, could be chance)` : ""}${l.testStarted ? "" : " \u2014 test not started"} \u2014 ${n}`;
1271
+ case "significant_positive":
1272
+ case "significant_negative":
1273
+ return `tested: ${l.liftPct != null && l.liftPct > 0 ? "+" : ""}${l.liftPct}% conversion${l.evidence === "significant_negative" ? " (worse)" : ""}${l.diffInterval ? `, range ${(l.diffInterval.lower * 100).toFixed(2)} to ${(l.diffInterval.upper * 100).toFixed(2)} pts` : ""} \u2014 ${n}`;
1274
+ default:
1275
+ return n;
1276
+ }
1277
+ }
1249
1278
  function registerStageTools(server, client) {
1250
1279
  server.registerTool(
1251
1280
  "get_stage_breakdown",
@@ -1254,7 +1283,7 @@ function registerStageTools(server, client) {
1254
1283
  // Mirrors GET /projects/:id/stage-report. Says OBSERVATIONAL in the
1255
1284
  // description because nothing serves on stage yet: an agent must not
1256
1285
  // read a per-context arm result as "stage-based serving would win".
1257
- description: "E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, holdout vs treated conversion per context (raw rates, observational \u2014 no significance test), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages." + UNTRUSTED_FIELDS_NOTE,
1286
+ description: `E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, how visitors moved between stages from one visit to the next, the TESTED holdout lift per first-visit context and per first-visit arrival channel (the same always-valid test as the headline lift, all-time, alpha split over all 23 possible series; "collecting" and "awaiting_conversions" are early, not results), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages.` + UNTRUSTED_FIELDS_NOTE,
1258
1287
  inputSchema: __spreadValues({ projectId: projectIdSchema }, rangeInputSchema("30d")),
1259
1288
  outputSchema: {
1260
1289
  enabled: import_zod6.z.boolean().describe("False when the project does not record funnel stages (settings.stage absent)"),
@@ -1267,11 +1296,29 @@ function registerStageTools(server, client) {
1267
1296
  contexts: import_zod6.z.array(import_zod6.z.object({
1268
1297
  context: import_zod6.z.string(),
1269
1298
  kind: import_zod6.z.string().describe("stage | arrival"),
1270
- sessions: import_zod6.z.number(),
1271
- treated: side,
1272
- holdout: side,
1273
- rawLiftPct: import_zod6.z.number().nullable().describe("Raw relative difference, %; null until both arms have 100 sessions. Observational.")
1299
+ sessions: import_zod6.z.number()
1274
1300
  })),
1301
+ holdoutLift: import_zod6.z.object({
1302
+ basis: import_zod6.z.string().describe("'first_visit': visitors grouped by their first visit's context/channel"),
1303
+ allTime: import_zod6.z.boolean().describe("True: the window does not apply to the lift"),
1304
+ goal: import_zod6.z.string(),
1305
+ alpha: import_zod6.z.number().describe("Per-series alpha after the correction"),
1306
+ family: import_zod6.z.number(),
1307
+ minPerArm: import_zod6.z.number(),
1308
+ units: import_zod6.z.number(),
1309
+ unclassified: import_zod6.z.number().describe("Exposed visitors with no first-visit record (exposed before recording began)"),
1310
+ contexts: import_zod6.z.array(segmentLift),
1311
+ channels: import_zod6.z.array(segmentLift)
1312
+ }).nullable().optional(),
1313
+ transitions: import_zod6.z.object({
1314
+ pairs: import_zod6.z.number().describe("Consecutive recorded visits in the window"),
1315
+ visitors: import_zod6.z.number(),
1316
+ rows: import_zod6.z.array(import_zod6.z.object({
1317
+ from: import_zod6.z.string(),
1318
+ nextVisits: import_zod6.z.number(),
1319
+ to: import_zod6.z.array(import_zod6.z.object({ stage: import_zod6.z.string(), visits: import_zod6.z.number(), visitors: import_zod6.z.number(), share: import_zod6.z.number().describe("0-1") }))
1320
+ }))
1321
+ }).optional(),
1275
1322
  arms: import_zod6.z.array(import_zod6.z.object({
1276
1323
  slotId: import_zod6.z.string(),
1277
1324
  slotName: import_zod6.z.string(),
@@ -1289,6 +1336,7 @@ function registerStageTools(server, client) {
1289
1336
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
1290
1337
  },
1291
1338
  withApiErrorGuidance(async ({ projectId, range, from, to }) => {
1339
+ var _a2;
1292
1340
  const id = encodeURIComponent(projectId);
1293
1341
  const r = await client.get(`/projects/${id}/stage-report${rangeQuery({ range, from, to })}`);
1294
1342
  const structuredContent = {
@@ -1300,6 +1348,8 @@ function registerStageTools(server, client) {
1300
1348
  channels: r.channels,
1301
1349
  arrivals: r.arrivals,
1302
1350
  contexts: r.contexts,
1351
+ holdoutLift: (_a2 = r.holdoutLift) != null ? _a2 : null,
1352
+ transitions: r.transitions,
1303
1353
  arms: r.arms,
1304
1354
  window: r.window
1305
1355
  };
@@ -1324,11 +1374,24 @@ function registerStageTools(server, client) {
1324
1374
  ...r.stages.map((s) => `- ${s.stage}: ${s.sessions} (${(s.share * 100).toFixed(1)}%)${s.hinted ? `, ${s.hinted} hinted` : ""}`),
1325
1375
  "",
1326
1376
  "Arrival channel x stage (conversion):",
1327
- ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`),
1328
- "",
1329
- "Holdout vs treated per context (raw rates, no significance test):",
1330
- ...r.contexts.filter((c) => c.sessions > 0).map((c) => `- ${c.context}: treated ${pct(c.treated.conversionRate)} (n=${c.treated.sessions}), holdout ${pct(c.holdout.conversionRate)} (n=${c.holdout.sessions})${c.rawLiftPct != null ? `, raw difference ${c.rawLiftPct > 0 ? "+" : ""}${c.rawLiftPct}%` : ""}`)
1377
+ ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`)
1331
1378
  ];
1379
+ const t = r.transitions;
1380
+ if (t && t.rows.length > 0) {
1381
+ lines.push("", `Stage moves between visits (${t.pairs} next visits from ${t.visitors} returning visitors in the window):`);
1382
+ for (const row of t.rows) {
1383
+ lines.push(`- from ${row.from} (${row.nextVisits} next visits): ${row.to.map((x) => `${x.stage} ${(x.share * 100).toFixed(0)}%`).join(", ")}`);
1384
+ }
1385
+ }
1386
+ const h = r.holdoutLift;
1387
+ if (h && (h.contexts.length > 0 || h.channels.length > 0)) {
1388
+ lines.push(
1389
+ "",
1390
+ `Holdout lift by first-visit context and channel (tested, all-time \u2014 the window does not apply; alpha ${h.alpha.toPrecision(2)} per series over ${h.family}; ${h.unclassified} of ${h.units} visitors predate recording and are not in any row):`,
1391
+ ...h.contexts.map((l) => `- context ${l.key}: ${liftWords(l, h.minPerArm)}`),
1392
+ ...h.channels.map((l) => `- channel ${l.key}: ${liftWords(l, h.minPerArm)}`)
1393
+ );
1394
+ }
1332
1395
  if (r.arms.length > 0) {
1333
1396
  lines.push("", "Versions per context (what stage-based serving would have to beat):");
1334
1397
  for (const a of r.arms) {
@@ -2633,7 +2696,8 @@ and then, in the same tag after the ';', runs the inline pre-paint script; then
2633
2696
  loader. The pre-paint script reads window.sentient when it runs, so sharing the tag is safe. It
2634
2697
  is optional but recommended \u2014 the loader is deferred, so on a return visit the page can paint
2635
2698
  its original state before the loader runs; the inline script applies the last served decision
2636
- from the visitor's own device first, with no network call and no cloak. Get the exact bytes from
2699
+ from the visitor's own device first, with no network call and no page-wide cloak (it briefly hides
2700
+ only published components, and only for a visitor who just clicked an ad or email). Get the exact bytes from
2637
2701
  the dashboard's Install page, or from renderSnippetInstall({ config }) in
2638
2702
  '@sentientui/snippet/install' (renderSnippetPrePaintScript() for the script alone).
2639
2703
 
@@ -3130,7 +3194,7 @@ function registerCellTools(server, client) {
3130
3194
  }
3131
3195
 
3132
3196
  // src/server.ts
3133
- var PKG_VERSION = true ? "0.23.0" : "0.0.0-dev";
3197
+ var PKG_VERSION = true ? "0.24.0" : "0.0.0-dev";
3134
3198
  function createMcpServer(client) {
3135
3199
  const server = new import_mcp.McpServer(
3136
3200
  {
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ApiClient,
4
4
  createMcpServer
5
- } from "./chunk-P3XRXJKT.js";
5
+ } from "./chunk-V3A552VK.js";
6
6
 
7
7
  // src/index.ts
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/lib.cjs CHANGED
@@ -1271,7 +1271,36 @@ function registerPersonaTools(server, client) {
1271
1271
  // src/tools/stages.ts
1272
1272
  var import_zod6 = require("zod");
1273
1273
  var pct = (v) => v == null ? "n/a (too few sessions)" : `${(v * 100).toFixed(1)}%`;
1274
- var side = import_zod6.z.object({ sessions: import_zod6.z.number(), conversions: import_zod6.z.number(), conversionRate: import_zod6.z.number().nullable() });
1274
+ var counts = import_zod6.z.object({ visitors: import_zod6.z.number(), conversions: import_zod6.z.number() });
1275
+ var segmentLift = import_zod6.z.object({
1276
+ key: import_zod6.z.string(),
1277
+ kind: import_zod6.z.string().describe("stage | arrival | channel"),
1278
+ evidence: import_zod6.z.string().describe("collecting | awaiting_conversions | not_significant | significant_positive | significant_negative"),
1279
+ testStarted: import_zod6.z.boolean().describe("False until the series has 100 visitors per side and 10 conversions (its test scale is then fixed)"),
1280
+ liftPct: import_zod6.z.number().nullable().describe("Relative lift %, null while collecting / awaiting conversions"),
1281
+ diffInterval: import_zod6.z.object({ lower: import_zod6.z.number(), upper: import_zod6.z.number() }).nullable().describe("Always-valid range on the absolute rate difference (fractions)"),
1282
+ pValue: import_zod6.z.number().nullable(),
1283
+ rates: import_zod6.z.object({ adapted: import_zod6.z.number(), control: import_zod6.z.number() }).nullable(),
1284
+ adapted: counts,
1285
+ control: counts,
1286
+ epochs: import_zod6.z.number()
1287
+ });
1288
+ function liftWords(l, minPerArm) {
1289
+ const n = `adapted ${l.adapted.visitors} / control ${l.control.visitors} visitors`;
1290
+ switch (l.evidence) {
1291
+ case "collecting":
1292
+ return `early \u2014 ${n}; a number shows at ${minPerArm} per side`;
1293
+ case "awaiting_conversions":
1294
+ return `early \u2014 ${n}; waiting on control-group conversions`;
1295
+ case "not_significant":
1296
+ return `not proven yet${l.liftPct != null ? ` (${l.liftPct > 0 ? "+" : ""}${l.liftPct}%, could be chance)` : ""}${l.testStarted ? "" : " \u2014 test not started"} \u2014 ${n}`;
1297
+ case "significant_positive":
1298
+ case "significant_negative":
1299
+ return `tested: ${l.liftPct != null && l.liftPct > 0 ? "+" : ""}${l.liftPct}% conversion${l.evidence === "significant_negative" ? " (worse)" : ""}${l.diffInterval ? `, range ${(l.diffInterval.lower * 100).toFixed(2)} to ${(l.diffInterval.upper * 100).toFixed(2)} pts` : ""} \u2014 ${n}`;
1300
+ default:
1301
+ return n;
1302
+ }
1303
+ }
1275
1304
  function registerStageTools(server, client) {
1276
1305
  server.registerTool(
1277
1306
  "get_stage_breakdown",
@@ -1280,7 +1309,7 @@ function registerStageTools(server, client) {
1280
1309
  // Mirrors GET /projects/:id/stage-report. Says OBSERVATIONAL in the
1281
1310
  // description because nothing serves on stage yet: an agent must not
1282
1311
  // read a per-context arm result as "stage-based serving would win".
1283
- description: "E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, holdout vs treated conversion per context (raw rates, observational \u2014 no significance test), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages." + UNTRUSTED_FIELDS_NOTE,
1312
+ description: `E-commerce projects: where visitors are in buying (new, browsing, carted, customer, lapsed) \u2014 share of human sessions per stage (each session counted once, at its latest recorded stage; hinted = set by a tagged campaign, not observed history), arrival channel x stage with conversion, how visitors moved between stages from one visit to the next, the TESTED holdout lift per first-visit context and per first-visit arrival channel (the same always-valid test as the headline lift, all-time, alpha split over all 23 possible series; "collecting" and "awaiting_conversions" are early, not results), and each region's versions per context judged against the original with the per-audience evidence rule. OBSERVATIONAL: nothing is served by stage yet; the per-context results are what stage-based serving would have to beat. Defaults to the last 30 days; pass range or from/to. Returns enabled:false when the project does not record stages.` + UNTRUSTED_FIELDS_NOTE,
1284
1313
  inputSchema: __spreadValues({ projectId: projectIdSchema }, rangeInputSchema("30d")),
1285
1314
  outputSchema: {
1286
1315
  enabled: import_zod6.z.boolean().describe("False when the project does not record funnel stages (settings.stage absent)"),
@@ -1293,11 +1322,29 @@ function registerStageTools(server, client) {
1293
1322
  contexts: import_zod6.z.array(import_zod6.z.object({
1294
1323
  context: import_zod6.z.string(),
1295
1324
  kind: import_zod6.z.string().describe("stage | arrival"),
1296
- sessions: import_zod6.z.number(),
1297
- treated: side,
1298
- holdout: side,
1299
- rawLiftPct: import_zod6.z.number().nullable().describe("Raw relative difference, %; null until both arms have 100 sessions. Observational.")
1325
+ sessions: import_zod6.z.number()
1300
1326
  })),
1327
+ holdoutLift: import_zod6.z.object({
1328
+ basis: import_zod6.z.string().describe("'first_visit': visitors grouped by their first visit's context/channel"),
1329
+ allTime: import_zod6.z.boolean().describe("True: the window does not apply to the lift"),
1330
+ goal: import_zod6.z.string(),
1331
+ alpha: import_zod6.z.number().describe("Per-series alpha after the correction"),
1332
+ family: import_zod6.z.number(),
1333
+ minPerArm: import_zod6.z.number(),
1334
+ units: import_zod6.z.number(),
1335
+ unclassified: import_zod6.z.number().describe("Exposed visitors with no first-visit record (exposed before recording began)"),
1336
+ contexts: import_zod6.z.array(segmentLift),
1337
+ channels: import_zod6.z.array(segmentLift)
1338
+ }).nullable().optional(),
1339
+ transitions: import_zod6.z.object({
1340
+ pairs: import_zod6.z.number().describe("Consecutive recorded visits in the window"),
1341
+ visitors: import_zod6.z.number(),
1342
+ rows: import_zod6.z.array(import_zod6.z.object({
1343
+ from: import_zod6.z.string(),
1344
+ nextVisits: import_zod6.z.number(),
1345
+ to: import_zod6.z.array(import_zod6.z.object({ stage: import_zod6.z.string(), visits: import_zod6.z.number(), visitors: import_zod6.z.number(), share: import_zod6.z.number().describe("0-1") }))
1346
+ }))
1347
+ }).optional(),
1301
1348
  arms: import_zod6.z.array(import_zod6.z.object({
1302
1349
  slotId: import_zod6.z.string(),
1303
1350
  slotName: import_zod6.z.string(),
@@ -1315,6 +1362,7 @@ function registerStageTools(server, client) {
1315
1362
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
1316
1363
  },
1317
1364
  withApiErrorGuidance(async ({ projectId, range, from, to }) => {
1365
+ var _a;
1318
1366
  const id = encodeURIComponent(projectId);
1319
1367
  const r = await client.get(`/projects/${id}/stage-report${rangeQuery({ range, from, to })}`);
1320
1368
  const structuredContent = {
@@ -1326,6 +1374,8 @@ function registerStageTools(server, client) {
1326
1374
  channels: r.channels,
1327
1375
  arrivals: r.arrivals,
1328
1376
  contexts: r.contexts,
1377
+ holdoutLift: (_a = r.holdoutLift) != null ? _a : null,
1378
+ transitions: r.transitions,
1329
1379
  arms: r.arms,
1330
1380
  window: r.window
1331
1381
  };
@@ -1350,11 +1400,24 @@ function registerStageTools(server, client) {
1350
1400
  ...r.stages.map((s) => `- ${s.stage}: ${s.sessions} (${(s.share * 100).toFixed(1)}%)${s.hinted ? `, ${s.hinted} hinted` : ""}`),
1351
1401
  "",
1352
1402
  "Arrival channel x stage (conversion):",
1353
- ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`),
1354
- "",
1355
- "Holdout vs treated per context (raw rates, no significance test):",
1356
- ...r.contexts.filter((c) => c.sessions > 0).map((c) => `- ${c.context}: treated ${pct(c.treated.conversionRate)} (n=${c.treated.sessions}), holdout ${pct(c.holdout.conversionRate)} (n=${c.holdout.sessions})${c.rawLiftPct != null ? `, raw difference ${c.rawLiftPct > 0 ? "+" : ""}${c.rawLiftPct}%` : ""}`)
1403
+ ...r.arrivals.map((a) => `- ${a.channel} / ${a.stage}: ${a.sessions} sessions, ${pct(a.conversionRate)}`)
1357
1404
  ];
1405
+ const t = r.transitions;
1406
+ if (t && t.rows.length > 0) {
1407
+ lines.push("", `Stage moves between visits (${t.pairs} next visits from ${t.visitors} returning visitors in the window):`);
1408
+ for (const row of t.rows) {
1409
+ lines.push(`- from ${row.from} (${row.nextVisits} next visits): ${row.to.map((x) => `${x.stage} ${(x.share * 100).toFixed(0)}%`).join(", ")}`);
1410
+ }
1411
+ }
1412
+ const h = r.holdoutLift;
1413
+ if (h && (h.contexts.length > 0 || h.channels.length > 0)) {
1414
+ lines.push(
1415
+ "",
1416
+ `Holdout lift by first-visit context and channel (tested, all-time \u2014 the window does not apply; alpha ${h.alpha.toPrecision(2)} per series over ${h.family}; ${h.unclassified} of ${h.units} visitors predate recording and are not in any row):`,
1417
+ ...h.contexts.map((l) => `- context ${l.key}: ${liftWords(l, h.minPerArm)}`),
1418
+ ...h.channels.map((l) => `- channel ${l.key}: ${liftWords(l, h.minPerArm)}`)
1419
+ );
1420
+ }
1358
1421
  if (r.arms.length > 0) {
1359
1422
  lines.push("", "Versions per context (what stage-based serving would have to beat):");
1360
1423
  for (const a of r.arms) {
@@ -2659,7 +2722,8 @@ and then, in the same tag after the ';', runs the inline pre-paint script; then
2659
2722
  loader. The pre-paint script reads window.sentient when it runs, so sharing the tag is safe. It
2660
2723
  is optional but recommended \u2014 the loader is deferred, so on a return visit the page can paint
2661
2724
  its original state before the loader runs; the inline script applies the last served decision
2662
- from the visitor's own device first, with no network call and no cloak. Get the exact bytes from
2725
+ from the visitor's own device first, with no network call and no page-wide cloak (it briefly hides
2726
+ only published components, and only for a visitor who just clicked an ad or email). Get the exact bytes from
2663
2727
  the dashboard's Install page, or from renderSnippetInstall({ config }) in
2664
2728
  '@sentientui/snippet/install' (renderSnippetPrePaintScript() for the script alone).
2665
2729
 
@@ -3156,7 +3220,7 @@ function registerCellTools(server, client) {
3156
3220
  }
3157
3221
 
3158
3222
  // src/server.ts
3159
- var PKG_VERSION = true ? "0.23.0" : "0.0.0-dev";
3223
+ var PKG_VERSION = true ? "0.24.0" : "0.0.0-dev";
3160
3224
  function createMcpServer(client) {
3161
3225
  const server = new import_mcp.McpServer(
3162
3226
  {
@@ -3224,7 +3288,7 @@ var TOOL_CATALOG = [
3224
3288
  { name: "list_guardrail_events", access: "read", summary: "Every variant currently paused by a guardrail (optionally only pauses from the last N days)" },
3225
3289
  // Audience and layout
3226
3290
  { name: "get_persona_breakdown", access: "read", summary: "Visitor clusters with session counts and reliability (all retained sessions)" },
3227
- { name: "get_stage_breakdown", access: "read", summary: "E-commerce funnel stages: stage mix, arrival channel x stage, holdout per context and versions per context (windowed, observational)" },
3291
+ { name: "get_stage_breakdown", access: "read", summary: "E-commerce funnel stages: stage mix, arrival channel x stage, stage moves between visits, tested holdout lift per first-visit context and channel, and versions per context (windowed, observational)" },
3228
3292
  { name: "get_layout_stats", access: "read", summary: "Per-persona section order with bandit pulls and reward (cumulative state)" },
3229
3293
  // Insights
3230
3294
  { name: "get_insights", access: "read", summary: "Ranked measured findings with their sample, plus AI narrations marked unmeasured" },
package/dist/lib.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  ApiError,
5
5
  PUBLIC_UI_RESOURCE_URIS,
6
6
  createMcpServer
7
- } from "./chunk-P3XRXJKT.js";
7
+ } from "./chunk-V3A552VK.js";
8
8
 
9
9
  // src/tool-catalog.ts
10
10
  var TOOL_CATALOG = [
@@ -34,7 +34,7 @@ var TOOL_CATALOG = [
34
34
  { name: "list_guardrail_events", access: "read", summary: "Every variant currently paused by a guardrail (optionally only pauses from the last N days)" },
35
35
  // Audience and layout
36
36
  { name: "get_persona_breakdown", access: "read", summary: "Visitor clusters with session counts and reliability (all retained sessions)" },
37
- { name: "get_stage_breakdown", access: "read", summary: "E-commerce funnel stages: stage mix, arrival channel x stage, holdout per context and versions per context (windowed, observational)" },
37
+ { name: "get_stage_breakdown", access: "read", summary: "E-commerce funnel stages: stage mix, arrival channel x stage, stage moves between visits, tested holdout lift per first-visit context and channel, and versions per context (windowed, observational)" },
38
38
  { name: "get_layout_stats", access: "read", summary: "Per-persona section order with bandit pulls and reward (cumulative state)" },
39
39
  // Insights
40
40
  { name: "get_insights", access: "read", summary: "Ranked measured findings with their sample, plus AI narrations marked unmeasured" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentientui/mcp",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "MCP server for SentientUI — exposes project data and actions to AI agents",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentient-ui.com",