@velaro/mcp-server 0.6.54 → 0.6.56

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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/package.json +7 -3
  3. package/server.js +428 -9
package/README.md CHANGED
@@ -132,6 +132,11 @@ This works with any MCP client that supports HTTP/SSE transport — including Cl
132
132
  | `team_list` | List all teams for this site |
133
133
  | `routing_list` | List all routing rules ordered by priority |
134
134
  | `routing_get` | Get routing rule details including rule and value JSON |
135
+ | `routing_get_policy` | Site auto-routing policy including reassignment mode and the agent inactivity chain |
136
+ | `routing_update_policy` | Update the policy, reassignment mode and inactivity chain (mode is always sent back) |
137
+ | `routing_simulate_inactivity` | DRY RUN of the inactivity chain for a mocked scenario, nothing is sent |
138
+ | `routing_explain_precedence` | Which routing rule wins when an agent goes silent, plus conflict warnings |
139
+ | `routing_inactivity_transfers` | Count agent inactivity transfers: total, per agent, per day |
135
140
 
136
141
  ### Site
137
142
  | Tool | What It Does |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "0.6.54",
3
+ "version": "0.6.56",
4
4
  "description": "Velaro MCP server — connect Claude and other AI agents directly to your Velaro account: KB, workflows, bots, conversations, contacts, routing, and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,8 @@
14
14
  "qs": "^6.16.0"
15
15
  },
16
16
  "overrides": {
17
- "qs": "^6.16.0"
17
+ "qs": "^6.16.0",
18
+ "hono": "^4.13.5"
18
19
  },
19
20
  "scripts": {
20
21
  "start": "node server.js"
@@ -43,5 +44,8 @@
43
44
  "server.js",
44
45
  "server.json",
45
46
  "README.md"
46
- ]
47
+ ],
48
+ "devDependencies": {
49
+ "license-checker-rseidelsohn": "^4.4.2"
50
+ }
47
51
  }
package/server.js CHANGED
@@ -62,6 +62,12 @@ const MESSAGING_API = process.env.VELARO_MESSAGING_API || null;
62
62
  const MCP_KEY = process.env.VELARO_MCP_KEY;
63
63
  const JWT = process.env.VELARO_JWT;
64
64
 
65
+ // mcp/staff-server.js defaults to staging instead -- the two servers' defaults intentionally
66
+ // differ, but a zero-result lookup gives no hint which environment was actually searched
67
+ // either way. See ENV_LABEL in staff-server.js for the incident this class of confusion caused
68
+ // (a real production customer read as "doesn't exist" from a staging-scoped search).
69
+ const ENV_LABEL = API_BASE.includes('staging') ? 'staging' : 'production';
70
+
65
71
  // Known admin API hosts, for tools that let a caller explicitly target one environment
66
72
  // regardless of what this server process's own API_BASE is configured to (see the `env` param
67
73
  // on the calendly_* superadmin tools below). Deliberately NOT used as a default anywhere —
@@ -561,6 +567,18 @@ const TOOLS = [
561
567
  },
562
568
  },
563
569
 
570
+ {
571
+ name: 'site_scraper_usage',
572
+ description: 'Velaro staff only. Scraper/upload/chunk consumption for ANY site in one call: pages this month vs budget (both the admin and messaging ledgers), upload pages, OCR pages, jobs today vs daily cap, and chunks indexed vs MaxIndexedChunkTotal with chunks grouped by index name. Wraps GET ScraperJobs/StaffUsage/{siteId} (IsVelaroAdminAsync + super-admin enforced server-side). Read-only.',
573
+ inputSchema: {
574
+ type: 'object',
575
+ properties: {
576
+ siteId: { type: 'number', description: 'Target site ID (need not be your own)' },
577
+ },
578
+ required: ['siteId'],
579
+ },
580
+ },
581
+
564
582
  // ── Startup migration diagnostics (AdminStartupDiagnostics canary table) ──
565
583
  {
566
584
  name: 'admin_startup_diagnostics',
@@ -1446,7 +1464,21 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1446
1464
  preferPreviousAgentOnTimeout: { type: 'boolean', description: 'On timeout, prefer agent from visitor\'s prior session (loyalty routing)' },
1447
1465
  agentFollowUpFlagEnabled: { type: 'boolean', description: 'Flag conversations that required multiple reroutes' },
1448
1466
  queueUnavailableGracePeriodSeconds: { type: 'number', description: 'Wait before returning unavailable when agents are transiently busy (seconds)' },
1449
- terminalActionOverride: { type: 'number', description: 'Terminal action: 0=Survey 1=CreateTicket 2=SurveyAndCreateTicket 3=Queue 4=OfflineMessage 5=NoOp. Null = default (Survey).' },
1467
+ terminalActionOverride: { type: 'number', description: 'Terminal action: 0=Survey 1=CreateTicket 2=SurveyAndCreateTicket 3=Queue 4=OfflineMessage 5=NoOp 6=EndChat 7=NotifyManager 8=RunWorkflow (needs inactivityTerminalWorkflowId). Null = default (Survey).' },
1468
+ exhaustionMessageTemplate: { type: 'string', description: 'Message sent to the visitor with the final action (max 500 characters).' },
1469
+ reassignmentMode: { type: 'string', enum: ['Observe','QueueOnly','Reassign'], description: "Observe (default) only records what would happen; QueueOnly returns a silent agent's chat to the group queue; Reassign hands it to the next agent. Backup group and no-agent-available steps need Reassign." },
1470
+ inactivityChainEnabled: { type: 'boolean', description: 'Master switch for the agent inactivity fallback chain. Off by default.' },
1471
+ inactivityBackupTeamId: { type: 'number', description: "Backup group to try when nobody in the chat's own group is available." },
1472
+ inactivityBackupTeamAfterSeconds: { type: 'number', description: 'Seconds with no available agent before the backup group is tried (0-7200).' },
1473
+ inactivityNoAgentActionAfterSeconds: { type: 'number', description: 'Seconds with no available agent before the final action runs (0-14400).' },
1474
+ inactivityTerminalWorkflowId: { type: 'number', description: 'Workflow to run when terminalActionOverride is 8.' },
1475
+ rerouteOnAgentUnavailable: { type: 'boolean', description: 'Presence trigger: also treat a chat as inactive when its agent is no longer Available.' },
1476
+ alertAvailableAgentsOnRelease: { type: 'boolean', description: 'Alert available agents when an inactive chat returns to the queue.' },
1477
+ notifyManagersOnInactivity: { type: 'boolean', description: 'Alert managers about inactive chats.' },
1478
+ rerouteCooldownSeconds: { type: 'number', description: 'Cooldown between moves of the same chat (0-3600).' },
1479
+ queueEscalationAfterMinutes: { type: 'number', description: 'Widen the queue alert to backup group and managers after this many minutes (1-240).' },
1480
+ inactivityVisitorText: { type: 'string', description: 'Message the visitor sees when the chat is moved (max 500 characters).' },
1481
+ inactivityAgentText: { type: 'string', description: 'Message agents see on a re-queued chat (max 500 characters).' },
1450
1482
  },
1451
1483
  required: [],
1452
1484
  },
@@ -1462,6 +1494,41 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1462
1494
  required: ['conversationId'],
1463
1495
  },
1464
1496
  },
1497
+ {
1498
+ name: 'routing_simulate_inactivity',
1499
+ description: 'DRY RUN: simulate what the agent inactivity chain would do for a mocked scenario, using the saved routing settings. Answers "what would happen if an agent goes silent and nobody is available for N minutes". Returns which rule applies, the safety-gate result (cooldown, duplicate, hop cap), the decision, the exact visitor and agent messages, and the rules that also matched but lost. Sends nothing and touches no live chat. Uses the same decision code the timer job runs.',
1500
+ inputSchema: {
1501
+ type: 'object',
1502
+ properties: {
1503
+ teamId: { type: 'number', description: 'Group id whose own policy to simulate. Omit for the site default.' },
1504
+ noAgentSeconds: { type: 'number', description: 'Seconds nobody has been available (0 = just now). Default 0.' },
1505
+ attemptsUsed: { type: 'number', description: 'Moves already used on the chat. Default 0.' },
1506
+ inactivityStep: { type: 'number', description: '0 = nothing tried, 1 = offered to backup group, 2 = final action applied. Default 0.' },
1507
+ secondsSinceLastTransfer: { type: 'number', description: 'Seconds since the chat was last moved. Omit if never moved.' },
1508
+ sameEpisodeAlreadyHandled: { type: 'boolean', description: 'True to simulate the same silence being delivered twice (duplicate guard).' },
1509
+ fromAgentName: { type: 'string', description: 'Name of the silent agent, used in the alert text.' },
1510
+ },
1511
+ required: [],
1512
+ },
1513
+ },
1514
+ {
1515
+ name: 'routing_explain_precedence',
1516
+ description: 'Explain which routing rule wins when an agent goes silent: the fixed evaluation order (group policy over site default, kill switch, reassignment mode, detector, safety gate, same-group agents, backup group, final action, and the other mechanisms this policy does not control), plus warnings where one policy shadows or contradicts another. Use for "why did the site default not apply to team X" or "which rule wins".',
1517
+ inputSchema: { type: 'object', properties: {}, required: [] },
1518
+ },
1519
+ {
1520
+ name: 'routing_inactivity_transfers',
1521
+ description: 'Count and break down "agent inactivity transfers" (chats taken from an agent who stopped responding) for your site: total, per agent, per day. Pass agentName (display name contains) or agentId to answer "how many inactivity transfers for agent X". Days 1 to 92, default 30.',
1522
+ inputSchema: {
1523
+ type: 'object',
1524
+ properties: {
1525
+ days: { type: 'number', description: 'Lookback in days (1-92). Default 30.' },
1526
+ agentName: { type: 'string', description: 'Agent display name (partial match) to filter to.' },
1527
+ agentId: { type: 'number', description: 'Exact agent id to filter to.' },
1528
+ },
1529
+ required: [],
1530
+ },
1531
+ },
1465
1532
  {
1466
1533
  name: 'routing_explain_decision',
1467
1534
  description: 'Plain-English explanation of every routing step for a conversation. Returns a human-readable summary ("10:32 AM: Warning sent to visitor… 10:37 AM: Rerouted from Alex → Sarah…"), the policy settings that were active, and enriched events with agent names resolved. Use to answer "why did this chat route to X?" or "why was this chat missed?"',
@@ -1544,6 +1611,120 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1544
1611
  description: 'Show what is currently indexed for the site: document counts per source type (kb_article, scraper_page, bigcommerce_product, etc). Use to verify ingestion completed and data is present.',
1545
1612
  inputSchema: { type: 'object', properties: {} },
1546
1613
  },
1614
+ {
1615
+ name: 'index_move_preview',
1616
+ description: 'Preview moving (or copying) documents between two knowledge indexes on the site. Writes nothing. Returns document/chunk/page counts, per-document method (copy_vectors, rechunk, already_in_target, blocked), quota effect, and whether it can run. No rescan and no OCR is ever needed. Moving into a troubleshooting index needs the EnableTroubleshootingKnowledge entitlement. Wraps POST IndexMove/Preview (velaro-messaging); the site comes from the caller auth, never an argument.',
1617
+ inputSchema: {
1618
+ type: 'object',
1619
+ properties: {
1620
+ fromIndex: { type: 'string', description: 'Source index name' },
1621
+ toIndex: { type: 'string', description: 'Target index name' },
1622
+ kind: { type: 'string', enum: ['move', 'copy'], description: 'Default move' },
1623
+ skipBlocked: { type: 'boolean', description: 'Leave blocked documents in place and move the rest' },
1624
+ selector: {
1625
+ type: 'object',
1626
+ description: 'What to move. Needs at least one field or all:true. Fields: all (bool), jobId, urlPrefix, sourceIds[], fileNames[], docIds[] (numbers), sourceType (website_page | video_transcript | uploaded_document | onedrive_file | custom_content).',
1627
+ },
1628
+ },
1629
+ required: ['fromIndex', 'toIndex', 'selector'],
1630
+ },
1631
+ },
1632
+ {
1633
+ name: 'index_move_execute',
1634
+ description: 'Run a move/copy between knowledge indexes. Always call index_move_preview first and check canExecute. Write-then-delete and resumable: content is never missing from both indexes. Returns a moveId; poll index_move_status. Wraps POST IndexMove/Execute.',
1635
+ inputSchema: {
1636
+ type: 'object',
1637
+ properties: {
1638
+ fromIndex: { type: 'string' },
1639
+ toIndex: { type: 'string' },
1640
+ kind: { type: 'string', enum: ['move', 'copy'] },
1641
+ skipBlocked: { type: 'boolean' },
1642
+ selector: {
1643
+ type: 'object',
1644
+ description: 'What to move. Needs at least one field or all:true. Fields: all (bool), jobId, urlPrefix, sourceIds[], fileNames[], docIds[] (numbers), sourceType (website_page | video_transcript | uploaded_document | onedrive_file | custom_content).',
1645
+ },
1646
+ },
1647
+ required: ['fromIndex', 'toIndex', 'selector'],
1648
+ },
1649
+ },
1650
+ {
1651
+ name: 'index_split_preview',
1652
+ description: 'Detect manual-like documents in a general index and preview moving them into an existing troubleshooting-purpose index. Writes nothing. Wraps POST IndexMove/Split/Preview. Use index_split_execute to run it.',
1653
+ inputSchema: {
1654
+ type: 'object',
1655
+ properties: {
1656
+ fromIndex: { type: 'string' },
1657
+ toIndex: { type: 'string', description: 'Existing troubleshooting-purpose index' },
1658
+ docIds: { type: 'array', items: { type: 'number' }, description: 'Only these documents (default: every detected manual)' },
1659
+ skipBlocked: { type: 'boolean' },
1660
+ },
1661
+ required: ['fromIndex', 'toIndex'],
1662
+ },
1663
+ },
1664
+ {
1665
+ name: 'index_split_execute',
1666
+ description: 'Run a split: move detected manuals from a general index into an existing troubleshooting-purpose index. Call index_split_preview first. Resumable and undoable via index_move_control. Wraps POST IndexMove/Split/Execute.',
1667
+ inputSchema: {
1668
+ type: 'object',
1669
+ properties: {
1670
+ fromIndex: { type: 'string' },
1671
+ toIndex: { type: 'string' },
1672
+ docIds: { type: 'array', items: { type: 'number' } },
1673
+ skipBlocked: { type: 'boolean' },
1674
+ },
1675
+ required: ['fromIndex', 'toIndex'],
1676
+ },
1677
+ },
1678
+ {
1679
+ name: 'index_move_status',
1680
+ description: 'Status of one move (moveId) or the recent moves (omit moveId): status, per-document progress, canResume/canAbort/canUndo. Wraps GET IndexMove/Moves[/{id}].',
1681
+ inputSchema: {
1682
+ type: 'object',
1683
+ properties: { moveId: { type: 'string' } },
1684
+ },
1685
+ },
1686
+ {
1687
+ name: 'index_move_control',
1688
+ description: 'Resume a stopped move, abort a queued/failed move that has not started removing content, or undo a completed move. Wraps POST IndexMove/Moves/{id}/Resume|Abort|Undo.',
1689
+ inputSchema: {
1690
+ type: 'object',
1691
+ properties: {
1692
+ moveId: { type: 'string' },
1693
+ action: { type: 'string', enum: ['resume', 'abort', 'undo'] },
1694
+ },
1695
+ required: ['moveId', 'action'],
1696
+ },
1697
+ },
1698
+ {
1699
+ name: 'index_mixed_content',
1700
+ description: 'Check whether a general index holds manual-like documents that belong in a Troubleshooting Assistant index. Read-only. Wraps GET IndexMove/MixedContent.',
1701
+ inputSchema: {
1702
+ type: 'object',
1703
+ properties: { indexName: { type: 'string' } },
1704
+ required: ['indexName'],
1705
+ },
1706
+ },
1707
+ {
1708
+ name: 'index_integrity_check',
1709
+ description: 'Read-only integrity report for a knowledge index: orphan chunks, records without chunks, count mismatches, duplicate records. Wraps GET IndexMove/Integrity.',
1710
+ inputSchema: {
1711
+ type: 'object',
1712
+ properties: { indexName: { type: 'string' } },
1713
+ required: ['indexName'],
1714
+ },
1715
+ },
1716
+ {
1717
+ name: 'index_integrity_repair',
1718
+ description: 'Repair a knowledge index. Additive and idempotent: corrects counts and adopts orphan chunks into tracking rows, never deletes vectors. removeDuplicateRecords also removes duplicate tracking rows. Run index_integrity_check first. Wraps POST IndexMove/Integrity/Repair.',
1719
+ inputSchema: {
1720
+ type: 'object',
1721
+ properties: {
1722
+ indexName: { type: 'string' },
1723
+ removeDuplicateRecords: { type: 'boolean' },
1724
+ },
1725
+ required: ['indexName'],
1726
+ },
1727
+ },
1547
1728
  {
1548
1729
  name: 'index_ingest',
1549
1730
  description: 'Trigger ingestion of a scraper job into the knowledge index. Fires in the background; use index_status after ~2 minutes to verify chunks appeared.',
@@ -2282,6 +2463,17 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2282
2463
  required: ['siteId'],
2283
2464
  },
2284
2465
  },
2466
+ {
2467
+ name: 'tour_insights',
2468
+ description: 'Moshky setup-tour funnel and feedback (Velaro staff only). Without siteId: per-tour, per-step counts of offered/opened/step_viewed/arrived/stuck/completed/dismissed/load_error over the last N days plus recent scrubbed feedback. With siteId: that one site\'s tour event timeline and feedback. Feedback comments are PII-scrubbed and for staff eyes only.',
2469
+ inputSchema: {
2470
+ type: 'object',
2471
+ properties: {
2472
+ siteId: { type: 'number', description: 'Optional. When set, returns that site\'s tour timeline instead of the cross-site funnel.' },
2473
+ days: { type: 'number', description: 'Look-back window in days (default 30, max 365)' },
2474
+ },
2475
+ },
2476
+ },
2285
2477
  {
2286
2478
  name: 'support_extend_subscription',
2287
2479
  description: 'Extend a customer site\'s subscription expiration by N days. SuperAdmin only.',
@@ -2886,6 +3078,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2886
3078
  },
2887
3079
  },
2888
3080
 
3081
+ {
3082
+ name: 'magento_diagnose',
3083
+ description: 'Run the Magento live-connection diagnostic for a site: config present, access token valid, /rest/V1 reachability, store code/base URL, and ACL scope probes. Calls messaging GET diagnostics/test-magento?siteId=<id>. Read-only.',
3084
+ inputSchema: {
3085
+ type: 'object',
3086
+ properties: {
3087
+ siteId: { type: 'number', description: 'Site to diagnose (e.g. 1032)' },
3088
+ },
3089
+ required: ['siteId'],
3090
+ },
3091
+ },
3092
+
2889
3093
  // ── CallRail Attribution ──────────────────────────────────────────────────────
2890
3094
  {
2891
3095
  name: 'callrail_get_attributions',
@@ -4248,6 +4452,34 @@ function parseBusinessHours(input) {
4248
4452
 
4249
4453
  // ── Tool handlers ─────────────────────────────────────────────────────────────
4250
4454
 
4455
+ // -- Agent inactivity chain helpers (velaro-messaging PR #1471, v20 ONLY) ------------------------
4456
+ // Terminal action ints are persisted by messaging (append-only enum). The API returns/accepts the NAME.
4457
+ const TERMINAL_ACTION_NAMES = ['Survey','CreateTicket','SurveyAndCreateTicket','Queue','OfflineMessage','NoOp','EndChat','NotifyManager','RunWorkflow'];
4458
+ function terminalActionLabel(v) {
4459
+ if (v === null || v === undefined || v === '') return 'Default (Survey)';
4460
+ if (typeof v === 'number') return TERMINAL_ACTION_NAMES[v] ?? String(v);
4461
+ return String(v);
4462
+ }
4463
+ function terminalActionForSave(v) {
4464
+ if (v === null || v === undefined || v === '') return null;
4465
+ const valid = TERMINAL_ACTION_NAMES.map((n, i) => `${i}=${n}`).join(' ');
4466
+ // Numeric strings ("7") count as ints, the same as the CLI.
4467
+ if (typeof v === 'string' && /^\d+$/.test(v.trim())) v = Number(v.trim());
4468
+ if (typeof v === 'number') {
4469
+ const name = Number.isInteger(v) ? TERMINAL_ACTION_NAMES[v] : undefined;
4470
+ if (!name) throw new Error(`Unknown terminal action ${v}. Use 0-${TERMINAL_ACTION_NAMES.length - 1}: ${valid}`);
4471
+ return name;
4472
+ }
4473
+ const hit = TERMINAL_ACTION_NAMES.find((n) => n.toLowerCase() === String(v).toLowerCase());
4474
+ if (!hit) throw new Error(`Unknown terminal action "${v}". Use one of: ${valid}`);
4475
+ return hit;
4476
+ }
4477
+ const CHAIN_FIELDS = [
4478
+ 'enabled','backupTeamId','backupTeamAfterSeconds','noTargetActionAfterSeconds','rerouteOnAgentUnavailable',
4479
+ 'alertAvailableAgentsOnRelease','notifyManagersOnInactivity','rerouteCooldownSeconds','queueEscalationAfterMinutes',
4480
+ 'terminalWorkflowId','visitorStatusText','agentStatusText',
4481
+ ];
4482
+
4251
4483
  async function handleTool(name, args) {
4252
4484
  switch (name) {
4253
4485
  case 'case_list_types': {
@@ -4506,6 +4738,11 @@ async function handleTool(name, args) {
4506
4738
  return JSON.stringify(result, null, 2);
4507
4739
  }
4508
4740
 
4741
+ case 'site_scraper_usage': {
4742
+ const result = await api('GET', `ScraperJobs/StaffUsage/${encodeURIComponent(args.siteId)}`);
4743
+ return JSON.stringify(result, null, 2);
4744
+ }
4745
+
4509
4746
  case 'apple_health': {
4510
4747
  const h = await messagingGet(`/SupportTools/sites/${args.siteId}/apple/health`);
4511
4748
  return JSON.stringify(h, null, 2);
@@ -5179,9 +5416,23 @@ async function handleTool(name, args) {
5179
5416
 
5180
5417
  case 'routing_get_policy': {
5181
5418
  const p = await messagingGet('/AutoRoutingPolicy');
5182
- const terminalLabel = p.terminalActionOverride != null
5183
- ? ['Survey','CreateTicket','SurveyAndCreateTicket','Queue','OfflineMessage','NoOp'][p.terminalActionOverride] ?? String(p.terminalActionOverride)
5184
- : 'Default (Survey)';
5419
+ const terminalLabel = terminalActionLabel(p.terminalActionOverride);
5420
+ const chain = p.inactivityChain;
5421
+ const chainLines = chain ? [
5422
+ '',
5423
+ `Reassignment mode: ${p.reassignmentMode ?? 'Observe'}`,
5424
+ `Inactivity chain: ${chain.enabled ? 'ON' : 'off'}`,
5425
+ ...(chain.enabled ? [
5426
+ ` Backup group: ${chain.backupTeamId ?? 'none'}${chain.backupTeamId != null ? ` after ${chain.backupTeamAfterSeconds}s` : ''}`,
5427
+ ` Final action after: ${chain.noTargetActionAfterSeconds != null ? `${chain.noTargetActionAfterSeconds}s with no agent available` : 'only when the hop cap is used up'}`,
5428
+ ` Cooldown between moves: ${chain.rerouteCooldownSeconds}s`,
5429
+ ` Presence trigger: ${chain.rerouteOnAgentUnavailable}`,
5430
+ ` Alert agents on release: ${chain.alertAvailableAgentsOnRelease}`,
5431
+ ` Notify managers: ${chain.notifyManagersOnInactivity}`,
5432
+ ` Queue escalation: ${chain.queueEscalationAfterMinutes != null ? `${chain.queueEscalationAfterMinutes} min` : 'never'}`,
5433
+ ` Terminal workflow: ${chain.terminalWorkflowId ?? 'none'}`,
5434
+ ] : []),
5435
+ ] : ['', '(This messaging build does not report the inactivity chain.)'];
5185
5436
  return [
5186
5437
  `Enabled: ${p.enabled}`,
5187
5438
  `Initial response timeout: ${p.initialResponseTimeoutSeconds}s`,
@@ -5193,6 +5444,7 @@ async function handleTool(name, args) {
5193
5444
  `Agent follow-up flag: ${p.agentFollowUpFlagEnabled}`,
5194
5445
  `Queue unavailable grace: ${p.queueUnavailableGracePeriodSeconds}s`,
5195
5446
  `Terminal action: ${terminalLabel}`,
5447
+ ...chainLines,
5196
5448
  ].join('\n');
5197
5449
  }
5198
5450
 
@@ -5209,8 +5461,46 @@ async function handleTool(name, args) {
5209
5461
  for (const f of fields) {
5210
5462
  if (args[f] !== undefined) merged[f] = args[f];
5211
5463
  }
5212
- const saved = await messagingPut('/AutoRoutingPolicy', merged);
5213
- return `Routing policy updated. Initial timeout: ${saved.initialResponseTimeoutSeconds}s, Idle timeout: ${saved.idleAfterResponseTimeoutSeconds}s, Max reroutes: ${saved.maxRerouteAttempts}.`;
5464
+ // Terminal action travels as the enum name; the API returns the name too, so normalize either way.
5465
+ merged.terminalActionOverride = terminalActionForSave(merged.terminalActionOverride);
5466
+ // RunWorkflow needs a workflow whatever the chain switch says: the final action also runs at the hop cap.
5467
+ // An explicit null clears the workflow, so it must fail the guard (?? would have fallen back to the saved one).
5468
+ const effectiveWorkflowId = args.inactivityTerminalWorkflowId !== undefined
5469
+ ? args.inactivityTerminalWorkflowId
5470
+ : current.inactivityChain?.terminalWorkflowId;
5471
+ if (merged.terminalActionOverride === 'RunWorkflow' && effectiveWorkflowId == null) {
5472
+ throw new Error('Terminal action 8 (RunWorkflow) needs inactivityTerminalWorkflowId.');
5473
+ }
5474
+ // Actions 6-8 exist only on a messaging build with the inactivity chain; an older one would store a different value.
5475
+ if (['EndChat', 'NotifyManager', 'RunWorkflow'].includes(merged.terminalActionOverride) && !('inactivityChain' in current)) {
5476
+ throw new Error(`Terminal action ${merged.terminalActionOverride} needs a messaging build with the agent inactivity chain; this one does not report it. Use 0-5.`);
5477
+ }
5478
+ if (args.exhaustionMessageTemplate !== undefined) merged.exhaustionMessageTemplate = args.exhaustionMessageTemplate;
5479
+ // Always send the mode back: an absent value is treated as Observe by the API.
5480
+ merged.reassignmentMode = args.reassignmentMode ?? current.reassignmentMode ?? 'Observe';
5481
+ const chainPatch = {
5482
+ enabled: args.inactivityChainEnabled,
5483
+ backupTeamId: args.inactivityBackupTeamId,
5484
+ backupTeamAfterSeconds: args.inactivityBackupTeamAfterSeconds,
5485
+ noTargetActionAfterSeconds: args.inactivityNoAgentActionAfterSeconds,
5486
+ terminalWorkflowId: args.inactivityTerminalWorkflowId,
5487
+ rerouteOnAgentUnavailable: args.rerouteOnAgentUnavailable,
5488
+ alertAvailableAgentsOnRelease: args.alertAvailableAgentsOnRelease,
5489
+ notifyManagersOnInactivity: args.notifyManagersOnInactivity,
5490
+ rerouteCooldownSeconds: args.rerouteCooldownSeconds,
5491
+ queueEscalationAfterMinutes: args.queueEscalationAfterMinutes,
5492
+ visitorStatusText: args.inactivityVisitorText,
5493
+ agentStatusText: args.inactivityAgentText,
5494
+ };
5495
+ if (current.inactivityChain || Object.values(chainPatch).some((v) => v !== undefined)) {
5496
+ const chain = { ...(current.inactivityChain ?? {}) };
5497
+ for (const f of CHAIN_FIELDS) if (chainPatch[f] !== undefined) chain[f] = chainPatch[f];
5498
+ merged.inactivityChain = chain;
5499
+ }
5500
+ await messagingPut('/AutoRoutingPolicy', merged);
5501
+ // PUT answers { updated: true }, not the policy: re-read so the reply reports what is really stored.
5502
+ const saved = await messagingGet('/AutoRoutingPolicy');
5503
+ return `Routing policy updated. Initial timeout: ${saved.initialResponseTimeoutSeconds}s, Idle timeout: ${saved.idleAfterResponseTimeoutSeconds}s, Max reroutes: ${saved.maxRerouteAttempts}, Reassignment mode: ${saved.reassignmentMode ?? 'n/a'}, Inactivity chain: ${saved.inactivityChain?.enabled ? 'ON' : 'off'}, Terminal action: ${terminalActionLabel(saved.terminalActionOverride)}.`;
5214
5504
  }
5215
5505
 
5216
5506
  case 'routing_get_decision_log': {
@@ -5241,13 +5531,81 @@ async function handleTool(name, args) {
5241
5531
  if (result.policySnapshot) {
5242
5532
  const p = result.policySnapshot;
5243
5533
  lines.push('');
5244
- lines.push(`Policy settings: initial timeout ${p.initialResponseTimeoutSeconds}s, idle timeout ${p.idleAfterResponseTimeoutSeconds}s, max reroutes ${p.maxRerouteAttempts}, terminal action ${p.terminalActionOverride ?? 'default (Survey)'}`);
5534
+ lines.push(`Policy settings: initial timeout ${p.initialResponseTimeoutSeconds}s, idle timeout ${p.idleAfterResponseTimeoutSeconds}s, max reroutes ${p.maxRerouteAttempts}, terminal action ${terminalActionLabel(p.terminalActionOverride)}`);
5245
5535
  if (!p.allowBounceBack) lines.push('Anti-bounce is ON — timed-out agents are excluded from re-routing for this conversation.');
5246
5536
  if (p.preferPreviousAgentOnTimeout) lines.push('Loyalty preference is ON — on timeout, the engine prefers the agent from a prior session.');
5247
5537
  }
5248
5538
  return lines.join('\n');
5249
5539
  }
5250
5540
 
5541
+ case 'routing_simulate_inactivity': {
5542
+ const body = {
5543
+ teamId: args.teamId ?? null,
5544
+ noAgentSeconds: args.noAgentSeconds ?? 0,
5545
+ attemptsUsed: args.attemptsUsed ?? 0,
5546
+ inactivityStep: args.inactivityStep ?? 0,
5547
+ secondsSinceLastTransfer: args.secondsSinceLastTransfer ?? null,
5548
+ sameEpisodeAlreadyHandled: args.sameEpisodeAlreadyHandled ?? false,
5549
+ fromAgentName: args.fromAgentName ?? null,
5550
+ };
5551
+ const r = await messagingPost('/Routing/Inactivity/simulate', body);
5552
+ const gate = {
5553
+ NotEnabled: "The inactivity chain is off for this rule, so today's behavior applies and nothing new happens.",
5554
+ Proceed: 'Allowed to move the chat.',
5555
+ SkipDedupe: 'Blocked: this exact silence was already handled (double-transfer guard).',
5556
+ SkipCooldown: 'Blocked: the chat was moved too recently (cooldown).',
5557
+ HopCapReached: 'Hop cap reached: no more moves, so the final action applies.',
5558
+ }[r.gate] ?? r.gate;
5559
+ const action = {
5560
+ LegacyDefer: 'Nothing new is configured, so the chat stays with its current owner.',
5561
+ Hold: 'Still inside the wait window, nothing happens yet.',
5562
+ WidenToBackupTeam: 'The chat is offered to the backup group.',
5563
+ ApplyTerminal: 'The final action runs.',
5564
+ }[r.noTargetAction] ?? r.noTargetAction;
5565
+ const lines = [
5566
+ 'DRY RUN (nothing sent, no live chat touched)',
5567
+ `Settings used: ${r.ruleSource}`,
5568
+ `Safety gate: ${gate}`,
5569
+ `Decision: ${action}`,
5570
+ r.explanation ? `Why: ${r.explanation}` : null,
5571
+ r.nextStepAtSeconds != null ? `Next step after ${r.nextStepAtSeconds}s with no agent available.` : null,
5572
+ `Visitor would see: "${r.visitorMessage}"`,
5573
+ `Agents would see: "${r.queueAlert || r.agentStatus}"`,
5574
+ ].filter(Boolean);
5575
+ for (const l of r.otherRulesThatMatchedButLost ?? []) lines.push(`Also matched but lost: ${l.rule} (${l.reason})`);
5576
+ return lines.join('\n');
5577
+ }
5578
+
5579
+ case 'routing_explain_precedence': {
5580
+ const [defaults, conflicts] = await Promise.all([
5581
+ messagingGet('/Routing/Inactivity/defaults'),
5582
+ messagingGet('/Routing/Inactivity/conflicts'),
5583
+ ]);
5584
+ const lines = ['Evaluation order (top to bottom):', ...(defaults.precedence ?? []).map((p) => ` ${p}`), ''];
5585
+ const list = conflicts.conflicts ?? [];
5586
+ if (!list.length) lines.push('Warnings: none. No group policy cancels out the site default.');
5587
+ else {
5588
+ lines.push('Warnings:');
5589
+ for (const c of list) lines.push(` [${String(c.severity).toUpperCase()}] ${c.teamId != null ? `group ${c.teamId}` : 'site default'}: ${c.message}`);
5590
+ }
5591
+ return lines.join('\n');
5592
+ }
5593
+
5594
+ case 'routing_inactivity_transfers': {
5595
+ let path = `/RoutingDiagnostics/inactivity-transfers?days=${args.days ?? 30}`;
5596
+ if (args.agentId != null) path += `&agentId=${args.agentId}`;
5597
+ if (args.agentName) path += `&agentName=${encodeURIComponent(args.agentName)}`;
5598
+ const r = await api('GET', path);
5599
+ if (r.note && !r.total && !(r.byAgent ?? []).length) return r.note;
5600
+ const lines = [`Agent inactivity transfers, last ${r.days} days: ${r.total}${r.capped ? ' (capped at 5000)' : ''}`];
5601
+ if (r.note) lines.push(r.note);
5602
+ if (r.matchedAgentsCapped) lines.push('The name matched more agents than are counted here. Use a longer name or agentId.');
5603
+ for (const a of r.byAgent ?? []) lines.push(` ${a.name}: ${a.count}`);
5604
+ if ((r.byDay ?? []).length) lines.push('', 'By day (Pacific): ' + r.byDay.map((d) => `${d.date}=${d.count}`).join(', '));
5605
+ if (!r.total) lines.push('None. Check that the inactivity chain is on and reassignment mode is not Observe.');
5606
+ return lines.join('\n');
5607
+ }
5608
+
5251
5609
  // ── Livefluence Migration ──────────────────────────────────────────────
5252
5610
  case 'livefluence_export': {
5253
5611
  const qs = args.v20_site_id ? `?v20SiteId=${args.v20_site_id}` : '';
@@ -5650,6 +6008,53 @@ async function handleTool(name, args) {
5650
6008
  return lines.join('\n');
5651
6009
  }
5652
6010
 
6011
+ case 'index_move_preview': {
6012
+ const { fromIndex, toIndex, kind, skipBlocked, selector } = args;
6013
+ const r = await messagingPost('/IndexMove/Preview', { fromIndex, toIndex, kind, skipBlocked, selector });
6014
+ return JSON.stringify(r, null, 2);
6015
+ }
6016
+ case 'index_move_execute': {
6017
+ const { fromIndex, toIndex, kind, skipBlocked, selector } = args;
6018
+ const r = await messagingPost('/IndexMove/Execute', { fromIndex, toIndex, kind, skipBlocked, selector });
6019
+ return JSON.stringify(r, null, 2);
6020
+ }
6021
+ case 'index_split_preview': {
6022
+ const body = { fromIndex: args.fromIndex, toIndex: args.toIndex, docIds: args.docIds, skipBlocked: args.skipBlocked };
6023
+ const r = await messagingPost('/IndexMove/Split/Preview', body);
6024
+ return JSON.stringify(r, null, 2);
6025
+ }
6026
+ case 'index_split_execute': {
6027
+ const body = { fromIndex: args.fromIndex, toIndex: args.toIndex, docIds: args.docIds, skipBlocked: args.skipBlocked };
6028
+ const r = await messagingPost('/IndexMove/Split/Execute', body);
6029
+ return JSON.stringify(r, null, 2);
6030
+ }
6031
+ case 'index_move_status': {
6032
+ const r = await messagingGet(args.moveId
6033
+ ? `/IndexMove/Moves/${encodeURIComponent(args.moveId)}`
6034
+ : '/IndexMove/Moves?take=25');
6035
+ return JSON.stringify(r, null, 2);
6036
+ }
6037
+ case 'index_move_control': {
6038
+ const verb = { resume: 'Resume', abort: 'Abort', undo: 'Undo' }[args.action];
6039
+ if (!verb) throw new Error('action must be resume, abort or undo');
6040
+ const r = await messagingPost(`/IndexMove/Moves/${encodeURIComponent(args.moveId)}/${verb}`);
6041
+ return JSON.stringify(r, null, 2);
6042
+ }
6043
+ case 'index_mixed_content': {
6044
+ const r = await messagingGet(`/IndexMove/MixedContent?index=${encodeURIComponent(args.indexName)}`);
6045
+ return JSON.stringify(r, null, 2);
6046
+ }
6047
+ case 'index_integrity_check': {
6048
+ const r = await messagingGet(`/IndexMove/Integrity?index=${encodeURIComponent(args.indexName)}`);
6049
+ return JSON.stringify(r, null, 2);
6050
+ }
6051
+ case 'index_integrity_repair': {
6052
+ const r = await messagingPost('/IndexMove/Integrity/Repair', {
6053
+ indexName: args.indexName,
6054
+ removeDuplicateRecords: !!args.removeDuplicateRecords,
6055
+ });
6056
+ return JSON.stringify(r, null, 2);
6057
+ }
5653
6058
  case 'index_status': {
5654
6059
  const sources = await api('GET', '/AzureIndexes/IntegrationSources');
5655
6060
  if (!Array.isArray(sources) || !sources.length) return 'No indexed sources found. Run index_ingest to populate.';
@@ -6539,7 +6944,7 @@ async function handleTool(name, args) {
6539
6944
 
6540
6945
  case 'support_user_lookup': {
6541
6946
  const users = await api('GET', `/SupportTools/users/search?q=${encodeURIComponent(args.query)}`);
6542
- if (!Array.isArray(users) || !users.length) return `No users found matching "${args.query}".`;
6947
+ if (!Array.isArray(users) || !users.length) return `No users found matching "${args.query}" in ${ENV_LABEL}. If this looks like a real customer, re-run with VELARO_ADMIN_API set to the other environment before concluding they don't exist.`;
6543
6948
  return users.map(u => {
6544
6949
  const roles = u.sites?.map(s =>
6545
6950
  ` site ${s.siteId} (${s.companyName}) — ${s.isAdministrator ? 'Admin' : s.isManager ? 'Manager' : 'Agent'}${s.isActive === false ? ' INACTIVE' : ''}`
@@ -6555,7 +6960,7 @@ async function handleTool(name, args) {
6555
6960
 
6556
6961
  case 'support_site_lookup': {
6557
6962
  const sites = await api('GET', `/SupportTools/sites/search?q=${encodeURIComponent(args.query)}`);
6558
- if (!Array.isArray(sites) || !sites.length) return `No sites found matching "${args.query}".`;
6963
+ if (!Array.isArray(sites) || !sites.length) return `No sites found matching "${args.query}" in ${ENV_LABEL}. If this looks like a real customer, re-run with VELARO_ADMIN_API set to the other environment before concluding they don't exist.`;
6559
6964
  return sites.map(s => {
6560
6965
  const admins = s.admins?.map(a => ` ${a.userName} (${a.firstName} ${a.lastName})`).join('\n') ?? 'none';
6561
6966
  return `[siteId=${s.siteId}] ${s.companyName}${s.isTestSite ? ' [TEST]' : ''}\n URL: ${s.companyURL ?? '—'} | Created: ${s.dateCreated ? s.dateCreated.slice(0,10) : '—'}\n Admins:\n${admins}`;
@@ -6652,6 +7057,14 @@ async function handleTool(name, args) {
6652
7057
  return lines.join('\n');
6653
7058
  }
6654
7059
 
7060
+ case 'tour_insights': {
7061
+ const days = Math.min(365, Math.max(1, args.days ?? 30));
7062
+ const path = args.siteId
7063
+ ? `/SupportTools/sites/${args.siteId}/tour-events?days=${days}`
7064
+ : `/SupportTools/tour-insights?days=${days}`;
7065
+ return JSON.stringify(await api('GET', path), null, 2);
7066
+ }
7067
+
6655
7068
  case 'support_extend_subscription': {
6656
7069
  const res = await api('POST', `/SupportTools/sites/${args.siteId}/extend-subscription`, { days: args.days });
6657
7070
  return res?.success
@@ -7231,6 +7644,12 @@ async function handleTool(name, args) {
7231
7644
  return lines.join('\n');
7232
7645
  }
7233
7646
 
7647
+ case 'magento_diagnose': {
7648
+ if (!args.siteId) return 'siteId is required.';
7649
+ const r = await api('GET', `/diagnostics/test-magento?siteId=${encodeURIComponent(args.siteId)}`);
7650
+ return JSON.stringify(r, null, 2);
7651
+ }
7652
+
7234
7653
  // ── CallRail Attribution ──────────────────────────────────────────────────
7235
7654
  case 'callrail_get_attributions': {
7236
7655
  const limit = Math.min(100, args.limit ?? 25);