@apex-inc/mcp-server 0.27.4 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -9,6 +9,7 @@ import { getPersonLabel, lowerPerson } from "./person-label.js";
9
9
  import { notStartedExplanation, publishedExperimentLine, } from "./publish-communication-copy.js";
10
10
  import { activationLiveLine, shouldRefuseActivationWiring, } from "./activation-wiring.js";
11
11
  import { milestoneWriteBody, preflightAdoptionMilestone, } from "./adoption-compose.js";
12
+ import { applyAddJourneyStep } from "./add-journey-step.js";
12
13
  const APEX = "Apex";
13
14
  /**
14
15
  * MOBX-006 — stable synthetic visitor id for agent-fired events.
@@ -4204,40 +4205,77 @@ Events fired through this tool carry a stable synthetic visitorId (mcp-agent-*,
4204
4205
  },
4205
4206
  },
4206
4207
  add_journey_step: {
4207
- description: `${APEX} — Append a wait or send step to a draft journey (inserted just before the exit; linear flows). 'wait' delays (ISO-8601 duration, e.g. P1D = 24h); 'send' fires a communication on the given channels. Call repeatedly to build trigger wait send exit.`,
4208
+ description: `${APEX} — Append a Wait or Send to a draft Adaptive Journey. Prefer compose_adoption_milestone for a Milestone (one Wait until they finish, letters already on the arms). Wait mode duration (ISO, e.g. P1D) or until_event (trigger_contract_id + deadline_iso). Send arm event|deadline puts the letter on that Wait arm. Do not add_journey_step send on a compose-built path use set_journey_send to point the existing Send.`,
4208
4209
  schema: z.object({
4209
4210
  journeyId: z.string(),
4210
4211
  kind: z.enum(["wait", "send"]),
4211
- durationIso: z.string().optional().describe("wait only: ISO-8601 duration, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
4212
+ mode: z.enum(["duration", "until_event"]).optional().describe("wait only. Default duration."),
4213
+ durationIso: z.string().optional().describe("wait duration: ISO-8601, e.g. 'P1D' (1 day), 'PT2H' (2 hours)."),
4214
+ triggerContractId: z.string().optional().describe("wait until_event: the done-event trigger contract."),
4215
+ deadlineIso: z.string().optional().describe("wait until_event: hours or days as ISO-8601, e.g. 'PT1H', 'P3D'."),
4212
4216
  commId: z.string().optional().describe("send only: the communication id to fire (create_communication or an existing comm)."),
4213
4217
  commVersion: z.number().optional().describe("send only: pinned comm version (default 1)."),
4214
4218
  channels: z.array(z.enum(["email", "in_app_push", "mobile_push", "web_push"])).optional().describe("send only: channels to fire (default ['email']). Use the SAME tokens as create_communication (email, in_app_push, mobile_push) so a comm's channels map cleanly onto the send step; web_push is send-only."),
4219
+ arm: z.enum(["event", "deadline"]).optional().describe("send only: put this Send on the until_event Wait's event or deadline arm."),
4215
4220
  }),
4216
4221
  handler: async (args) => {
4217
4222
  try {
4218
- if (args.kind === "send" && !args.commId) {
4219
- return { content: [{ type: "text", text: `${APEX} A send step needs commId.` }], isError: true };
4223
+ const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
4224
+ const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
4225
+ const applied = applyAddJourneyStep(j.steps ?? [], args, newId);
4226
+ if (!applied.ok) {
4227
+ return { content: [{ type: "text", text: `${APEX} ${applied.error}` }], isError: true };
4220
4228
  }
4229
+ const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps: applied.steps });
4230
+ if (!tenantOk(updated.workspaceKey))
4231
+ return tenantMismatch();
4232
+ return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step ${applied.stepId} to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
4233
+ }
4234
+ catch (err) {
4235
+ return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
4236
+ }
4237
+ },
4238
+ },
4239
+ set_journey_send: {
4240
+ description: `${APEX} — Point a draft journey's send step at a communication. Use after create_communication + edit_communication so the letter on the journey is yours, not the shared starter template. If the journey has one send, that send is updated. If it has several, pass step_id.`,
4241
+ schema: z.object({
4242
+ journeyId: z.string(),
4243
+ commId: z.string().describe("The communication id from create_communication."),
4244
+ stepId: z.string().optional().describe("Required when the journey has more than one send."),
4245
+ commVersion: z.number().optional().describe("Pinned comm version. Default 1."),
4246
+ }),
4247
+ handler: async (args) => {
4248
+ try {
4221
4249
  const j = await apiGet(`/api/journeys/${encodeURIComponent(args.journeyId)}`);
4222
4250
  const steps = [...(j.steps ?? [])];
4223
- const exit = steps.find((s) => s.type === "exit");
4224
- if (!exit) {
4225
- return { content: [{ type: "text", text: `${APEX} Journey has no exit step; cannot insert.` }], isError: true };
4251
+ const sends = steps.filter((s) => s.type === "send");
4252
+ const target = args.stepId
4253
+ ? sends.find((s) => s.id === args.stepId)
4254
+ : sends.length === 1
4255
+ ? sends[0]
4256
+ : undefined;
4257
+ if (!target) {
4258
+ return {
4259
+ content: [{
4260
+ type: "text",
4261
+ text: sends.length === 0
4262
+ ? `${APEX} This journey has no send step. Use add_journey_step kind=send.`
4263
+ : `${APEX} This journey has ${sends.length} send steps. Pass step_id (${sends.map((s) => s.id).join(", ")}).`,
4264
+ }],
4265
+ isError: true,
4266
+ };
4226
4267
  }
4227
- const exitId = exit.id;
4228
- const newId = `step_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
4229
- const newStep = args.kind === "wait"
4230
- ? { id: newId, type: "wait", label: "Wait", mode: "duration", duration: args.durationIso ?? "P1D", next: exitId }
4231
- : { id: newId, type: "send", label: "Send", commId: args.commId, commVersion: args.commVersion ?? 1, channels: args.channels ?? ["email"], next: exitId };
4232
- // Insert before the exit: rewire whatever currently points to exit.
4233
- const pre = steps.find((s) => s.next === exitId);
4234
- if (pre)
4235
- pre.next = newId;
4236
- steps.push(newStep);
4268
+ target.commId = args.commId;
4269
+ target.commVersion = args.commVersion ?? 1;
4237
4270
  const updated = await apiPatch(`/api/journeys/${encodeURIComponent(args.journeyId)}`, { steps });
4238
4271
  if (!tenantOk(updated.workspaceKey))
4239
4272
  return tenantMismatch();
4240
- return { content: [{ type: "text", text: `${APEX} Added ${args.kind} step to journey ${args.journeyId}. Watch it update in the browser: ${journeyLink(args.journeyId)}` }] };
4273
+ return {
4274
+ content: [{
4275
+ type: "text",
4276
+ text: `${APEX} Send step ${String(target.id)} now uses ${args.commId}. View it: ${journeyLink(args.journeyId)}`,
4277
+ }],
4278
+ };
4241
4279
  }
4242
4280
  catch (err) {
4243
4281
  return { content: [{ type: "text", text: `${APEX} ${errMsg(err)}` }], isError: true };
@@ -5448,6 +5486,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5448
5486
  schema: z.object({
5449
5487
  name: z.string().describe("Human label, e.g. 'Ran first report'"),
5450
5488
  featureKey: z.string().describe("Stable feature key, e.g. 'reporting'"),
5489
+ actionLabel: z.string().optional().describe("Sentence verb: hasn't {action_label}. Never the raw event key."),
5451
5490
  adoptedWhenKind: z.enum(["event", "trait"]).describe("How adoption is detected"),
5452
5491
  eventName: z.string().optional().describe("event kind: the event whose firing means adopted"),
5453
5492
  traitPath: z.string().optional().describe("trait kind: dotted trait path, e.g. 'plan'"),
@@ -5484,6 +5523,7 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5484
5523
  schema: z.object({
5485
5524
  milestoneId: z.string(),
5486
5525
  name: z.string().optional(),
5526
+ actionLabel: z.string().optional().describe("Sentence verb: hasn't {action_label}."),
5487
5527
  priority: z.number().optional(),
5488
5528
  order: z.number().optional().describe("0-based sequence order. Prefer reorder_adoption_milestones to sequence the whole set."),
5489
5529
  active: z.boolean().optional(),
@@ -5515,10 +5555,11 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5515
5555
  },
5516
5556
  },
5517
5557
  compose_adoption_milestone: {
5518
- description: `${APEX} — Create an adoption milestone AND scaffold its journey legs in ONE call. scaffold="both" (default, recommended) creates the nudge (hasn't adopted within the gap) AND the celebration. New milestones default to enroll_mode="future" so a short clock does not mail the historical roster. gap_hours wins over gap_days. Journeys are drafts — preview the letter, then publish_journey. Returns the milestone, journey ids, and honesty warnings. A milestone is NOT a goal.`,
5558
+ description: `${APEX} — Create a Milestone and stamp ONE Adaptive Journey (Trigger Wait until they finish → Celebration and/or Nudge). scaffold="both" (default) includes both letters; "nudge" / "celebration" one letter; "none" is just-track. Unique Communication per Send — never reuse the shared catalog letters. New milestones default to enroll_mode="future". Journeys are drafts — preview, then publish_journey. A Milestone is NOT a Target.`,
5519
5559
  schema: z.object({
5520
5560
  name: z.string().describe("Human label, e.g. 'Started first experiment'"),
5521
5561
  featureKey: z.string().describe("Stable feature key, e.g. 'experiments'"),
5562
+ actionLabel: z.string().optional().describe("Sentence verb: hasn't {action_label}."),
5522
5563
  adoptedWhenKind: z.enum(["event", "trait"]).describe("How adoption is detected"),
5523
5564
  eventName: z.string().optional().describe("event kind: the event whose firing means adopted"),
5524
5565
  traitPath: z.string().optional().describe("trait kind: dotted trait path, e.g. 'plan'"),
@@ -5526,19 +5567,33 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5526
5567
  traitOp: z.enum(["equals", "not_equals", "exists"]).optional(),
5527
5568
  priority: z.number().optional().describe("Lower is nudged first. Default 100."),
5528
5569
  order: z.number().optional().describe("0-based sequence order."),
5529
- gapDays: z.number().optional().describe("Nudge window in days. Ignored when gap_hours is set. Default 3."),
5530
- gapHours: z.number().optional().describe("Nudge window in hours. Use for same-day QA. 0 = right away."),
5570
+ gapDays: z.number().optional().describe("First Nudge window in days. Ignored when gap_hours or nudge_clocks is set. Default 3."),
5571
+ gapHours: z.number().optional().describe("First Nudge window in hours. 0 = right away."),
5572
+ nudgeClocks: z
5573
+ .array(z.object({
5574
+ hours: z.number().optional(),
5575
+ days: z.number().optional(),
5576
+ }))
5577
+ .optional()
5578
+ .describe("Each clock is N hours or N days. First is the Wait deadline; later are extra Nudges."),
5531
5579
  gapKind: z.enum(["days_since_signup", "event_not_fired"]).optional(),
5532
5580
  sinceEventName: z.string().optional(),
5533
5581
  enrollMode: z.enum(["all", "future"]).optional().describe("future (default) = only people who sign up from now on."),
5534
5582
  active: z.boolean().optional().describe("Turn the milestone on. Default false."),
5535
- scaffold: z.enum(["both", "celebration", "nudge", "none"]).optional().describe("Which legs to scaffold. Default both."),
5536
- journeyName: z.string().optional().describe("Name for a single scaffolded journey. Ignored for scaffold='both'."),
5583
+ scaffold: z.enum(["both", "celebration", "nudge", "none"]).optional().describe("Which letters to include. Default both."),
5584
+ journeyName: z.string().optional().describe("Name for the Adaptive Journey. Default is the Milestone name."),
5585
+ saveNotYetSegment: z.boolean().optional().describe("Save the live not-yet rule as a Segment."),
5586
+ saveDoneSegment: z.boolean().optional().describe("Save the live done rule as a Segment."),
5537
5587
  }),
5538
5588
  handler: async (args) => {
5539
5589
  const warnings = await preflightAdoptionMilestone(args);
5590
+ const firstClock = args.nudgeClocks?.[0];
5540
5591
  const created = await apiPost("/api/adoption/milestones", milestoneWriteBody({
5541
5592
  ...args,
5593
+ ...(firstClock?.hours !== undefined ? { gapHours: firstClock.hours } : {}),
5594
+ ...(firstClock?.days !== undefined && firstClock.hours === undefined
5595
+ ? { gapDays: firstClock.days }
5596
+ : {}),
5542
5597
  enrollMode: args.enrollMode ?? "future",
5543
5598
  active: args.active ?? false,
5544
5599
  priority: args.priority ?? 100,
@@ -5547,37 +5602,54 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5547
5602
  if (!milestoneId) {
5548
5603
  return { content: [{ type: "text", text: JSON.stringify(created, null, 2) }] };
5549
5604
  }
5605
+ const persistSegments = async () => {
5606
+ if (!args.saveNotYetSegment && !args.saveDoneSegment) {
5607
+ return created.data?.milestone;
5608
+ }
5609
+ const patched = await apiPatch(`/api/adoption/milestones/${encodeURIComponent(milestoneId)}`, {
5610
+ saveNotYetSegment: args.saveNotYetSegment === true,
5611
+ saveDoneSegment: args.saveDoneSegment === true,
5612
+ });
5613
+ return patched.data?.milestone ?? created.data?.milestone;
5614
+ };
5550
5615
  const scaffold = args.scaffold ?? "both";
5551
5616
  if (scaffold === "none") {
5617
+ const milestone = await persistSegments();
5552
5618
  return {
5553
- content: [{ type: "text", text: JSON.stringify({ milestone: created.data?.milestone, journeyId: null, nudgeJourneyId: null, celebrationJourneyId: null, warnings }, null, 2) }],
5619
+ content: [{ type: "text", text: JSON.stringify({
5620
+ milestone,
5621
+ journey_id: null,
5622
+ nudge_journey_id: null,
5623
+ celebration_journey_id: null,
5624
+ sends: [],
5625
+ warnings,
5626
+ }, null, 2) }],
5554
5627
  };
5555
5628
  }
5556
- const scaffoldLeg = async (templateId, name) => {
5557
- const journey = await apiPost("/api/journeys/from-template", {
5558
- templateId,
5559
- milestoneId,
5560
- ...(name ? { name } : {}),
5561
- });
5562
- return journey?.id ?? null;
5563
- };
5564
- const wantsNudge = scaffold === "nudge" || scaffold === "both";
5565
- const wantsCelebration = scaffold === "celebration" || scaffold === "both";
5566
- // Single-leg scaffolds honor journeyName; "both" lets each leg default.
5567
- const legName = scaffold === "both" ? undefined : args.journeyName;
5568
- const nudgeJourneyId = wantsNudge ? await scaffoldLeg("adoption-nudge", legName) : null;
5569
- const celebrationJourneyId = wantsCelebration
5570
- ? await scaffoldLeg("adoption-celebration", legName)
5571
- : null;
5629
+ const journey = await apiPost("/api/journeys/from-template", {
5630
+ templateId: "adoption-nudge",
5631
+ milestoneId,
5632
+ scaffold,
5633
+ name: args.journeyName ?? args.name,
5634
+ ...(args.nudgeClocks ? { nudgeClocks: args.nudgeClocks } : {}),
5635
+ });
5636
+ const journeyId = journey?.id ?? null;
5637
+ const sends = (journey?.sends ?? []).map((s) => ({
5638
+ role: s.role,
5639
+ step_id: s.stepId,
5640
+ comm_id: s.commId,
5641
+ }));
5642
+ const milestone = await persistSegments();
5572
5643
  return {
5573
5644
  content: [
5574
5645
  {
5575
5646
  type: "text",
5576
5647
  text: JSON.stringify({
5577
- milestone: created.data?.milestone,
5578
- journeyId: nudgeJourneyId ?? celebrationJourneyId,
5579
- nudgeJourneyId,
5580
- celebrationJourneyId,
5648
+ milestone,
5649
+ journey_id: journeyId,
5650
+ nudge_journey_id: journeyId,
5651
+ celebration_journey_id: null,
5652
+ sends,
5581
5653
  warnings,
5582
5654
  }, null, 2),
5583
5655
  },
@@ -5585,6 +5657,115 @@ _Suggest the next step the user should tackle based on what's incomplete in the
5585
5657
  };
5586
5658
  },
5587
5659
  },
5660
+ attach_adoption_milestone_still: {
5661
+ description: `${APEX} — Attach a still of the empty screen (not_yet) or the finished screen (done) to a Milestone. Same two pictures the shop uploads. Pass imageBase64 or a public url. A Milestone is NOT a Target.`,
5662
+ schema: z.object({
5663
+ milestoneId: z.string().describe("The Milestone to attach the still to"),
5664
+ state: z.enum(["not_yet", "done"]).describe("not_yet = empty screen; done = finished screen"),
5665
+ imageBase64: z.string().optional().describe("Base64-encoded PNG/JPEG."),
5666
+ url: z.string().url().optional().describe("Already-hosted public image URL."),
5667
+ viewport: z.enum(["desktop", "tablet", "mobile"]).optional(),
5668
+ format: z.enum(["png", "jpeg"]).optional(),
5669
+ label: z.string().optional(),
5670
+ }),
5671
+ handler: async (args) => {
5672
+ if (!args.imageBase64 && !args.url) {
5673
+ return {
5674
+ content: [{
5675
+ type: "text",
5676
+ text: "Provide either imageBase64 or url.",
5677
+ }],
5678
+ isError: true,
5679
+ };
5680
+ }
5681
+ const res = await apiPost(`/api/adoption/milestones/${encodeURIComponent(args.milestoneId)}/assets`, {
5682
+ state: args.state,
5683
+ source: args.imageBase64 ? "agent" : "upload",
5684
+ viewport: args.viewport ?? "desktop",
5685
+ imageBase64: args.imageBase64,
5686
+ url: args.url,
5687
+ format: args.format ?? "png",
5688
+ label: args.label,
5689
+ });
5690
+ const asset = res.data;
5691
+ return {
5692
+ content: [{
5693
+ type: "text",
5694
+ text: JSON.stringify({
5695
+ id: asset.id,
5696
+ state: asset.state,
5697
+ url: asset.url,
5698
+ milestone_id: args.milestoneId,
5699
+ }, null, 2),
5700
+ }],
5701
+ };
5702
+ },
5703
+ },
5704
+ recapture_adoption_milestone_stills: {
5705
+ description: `${APEX} — Capture the Not yet and Done stills from public pages on a named workspace host. Not localhost. A login wall fails honestly and writes nothing — attach a still yourself. Same two pictures the shop uploads.`,
5706
+ schema: z.object({
5707
+ milestoneId: z.string().describe("The Milestone to capture stills for"),
5708
+ notYetUrl: z.string().url().optional().describe("Public https URL of the empty screen."),
5709
+ doneUrl: z.string().url().optional().describe("Public https URL of the finished screen."),
5710
+ }),
5711
+ handler: async (args) => {
5712
+ if (!args.notYetUrl && !args.doneUrl) {
5713
+ return {
5714
+ content: [{ type: "text", text: "Name a public page for Not yet or Done." }],
5715
+ isError: true,
5716
+ };
5717
+ }
5718
+ try {
5719
+ const res = await apiPost(`/api/adoption/milestones/${encodeURIComponent(args.milestoneId)}/recapture`, {
5720
+ notYetUrl: args.notYetUrl,
5721
+ doneUrl: args.doneUrl,
5722
+ });
5723
+ if (res.error) {
5724
+ return {
5725
+ content: [{
5726
+ type: "text",
5727
+ text: res.error,
5728
+ }],
5729
+ isError: true,
5730
+ };
5731
+ }
5732
+ return {
5733
+ content: [{
5734
+ type: "text",
5735
+ text: JSON.stringify({
5736
+ captured: res.data?.captured ?? [],
5737
+ failed: res.data?.failed ?? [],
5738
+ milestone_id: args.milestoneId,
5739
+ }, null, 2),
5740
+ }],
5741
+ };
5742
+ }
5743
+ catch {
5744
+ return {
5745
+ content: [{
5746
+ type: "text",
5747
+ text: "We couldn’t open that page from Apex. It may need a sign-in. Attach a still yourself.",
5748
+ }],
5749
+ isError: true,
5750
+ };
5751
+ }
5752
+ },
5753
+ },
5754
+ list_adoption_milestone_stills: {
5755
+ description: `${APEX} — List the Not yet and Done stills attached to a Milestone. Empty means they have not attached one yet.`,
5756
+ schema: z.object({
5757
+ milestoneId: z.string().describe("The Milestone to list stills for"),
5758
+ }),
5759
+ handler: async ({ milestoneId }) => {
5760
+ const res = await apiGet(`/api/adoption/milestones/${encodeURIComponent(milestoneId)}/assets`);
5761
+ return {
5762
+ content: [{
5763
+ type: "text",
5764
+ text: JSON.stringify(res.data ?? [], null, 2),
5765
+ }],
5766
+ };
5767
+ },
5768
+ },
5588
5769
  reorder_adoption_milestones: {
5589
5770
  description: `${APEX} — Set the sequence order of adoption milestones in one call. Pass the milestone ids in the desired order; each gets order = its index. Ordering the full set unlocks the "Path" funnel in the account view (a step-by-step drop-off chart that is only honest over an explicitly-ordered set).`,
5590
5771
  schema: z.object({