@velaro/mcp-server 0.6.102 → 0.6.104

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/server.js CHANGED
@@ -40,7 +40,7 @@
40
40
  * VELARO_MCP_MODE=http PORT=3000 node server.js
41
41
  */
42
42
 
43
- import { describeRawMeta, fetchRetry429 } from './log-search.js';
43
+ import { buildRawLogQuery, describeRawMeta, fetchRetry429, resolveLogWindow } from './log-search.js';
44
44
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
45
45
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
46
46
  import {
@@ -49,6 +49,8 @@ import {
49
49
  GetPromptRequestSchema,
50
50
  ListPromptsRequestSchema,
51
51
  } from '@modelcontextprotocol/sdk/types.js';
52
+ import { withAnnotations } from './tool-annotations.js';
53
+ import { resolveMessagingApi } from './messaging-base.js';
52
54
 
53
55
  // Default was 'https://help.velaro.com'. Renamed to the recognizable admin API hostname for
54
56
  // clarity during the 2026-09-10 Donaldson bot Calendly status investigation
@@ -59,7 +61,7 @@ import {
59
61
  // caused that investigation's status/reality contradiction, it was NOT this default resolving to a
60
62
  // broken or different host; that theory is disproven. Left as a naming clarity improvement only.
61
63
  const API_BASE = process.env.VELARO_ADMIN_API || 'https://api-admin-us-east.velaro.com';
62
- const MESSAGING_API = process.env.VELARO_MESSAGING_API || null;
64
+ const MESSAGING_API = resolveMessagingApi(process.env.VELARO_MESSAGING_API, API_BASE);
63
65
  const MCP_KEY = process.env.VELARO_MCP_KEY;
64
66
  const JWT = process.env.VELARO_JWT;
65
67
 
@@ -320,7 +322,7 @@ async function gatewayInit() {
320
322
  // Tools served by velaro-messaging's admin-tools gateway (McpAdminGatewayController, POST /mcp/v1/admin-tools/call,
321
323
  // no session). The admin /mcp gateway only serves kb_* tools, so these used to fail there.
322
324
  const MESSAGING_ADMIN_TOOLS = new Set([
323
- 'workflow_create', 'workflow_list', 'workflow_get', 'workflow_toggle',
325
+ 'workflow_create', 'workflow_list', 'workflow_get', 'workflow_toggle', 'workflow_run_now',
324
326
  'bot_create', 'bot_list', 'bot_get', 'bot_update_prompt', 'bot_set_knowledge',
325
327
  'ai_config_attach_index', 'ai_config_set_reply_format', 'call_pop_settings_get', 'call_pop_settings_set',
326
328
  'crm_mobile_fields_get', 'crm_mobile_fields_set',
@@ -488,7 +490,7 @@ const TOOLS = [
488
490
  },
489
491
  {
490
492
  name: 'notifications_get',
491
- description: "Get the calling agent's own notification/SMS alert settings (new conversation, missed chat, unassign warning, text-alert toggles, SMS phone number).",
493
+ description: "Get the calling agent's own notification/SMS alert settings (new conversation, missed chat, unassign warning, text-alert toggles, SMS phone number) plus alertRouting: which Channel (web, sms, whatsapp, appleMessages, email, phone) x Handling (mine, unassigned, bot, teammates) x Kind (conversations, tickets, calls, visitors) alerts fire, and ivrBotAlerts (opt-in for inbound IVR bot chats).",
492
494
  inputSchema: { type: 'object', properties: {}, required: [] },
493
495
  },
494
496
  {
@@ -508,6 +510,35 @@ const TOOLS = [
508
510
  smsOnUnassignWarning: { type: 'boolean', description: 'Send an SMS on unassign warning' },
509
511
  useProfilePhone: { type: 'boolean', description: 'Use the profile phone number for SMS alerts' },
510
512
  smsAlertPhone: { type: 'string', description: 'Phone number for SMS alerts (E.164, e.g. +14155551234)' },
513
+ alertRouting: {
514
+ type: 'object',
515
+ description: 'Alert routing, partial update (omitted keys unchanged). An alert fires only if its channel, handling and kind are all on. Example: {"channels":{"sms":true,"web":false},"handling":{"mine":true,"unassigned":false},"kinds":{"tickets":false},"ivrBotAlerts":true}',
516
+ properties: {
517
+ channels: { type: 'object', description: 'Keys: web, sms, whatsapp, appleMessages, email, phone (booleans)' },
518
+ handling: { type: 'object', description: 'Keys: mine, unassigned, bot, teammates (booleans, multi-select)' },
519
+ kinds: { type: 'object', description: 'Keys: conversations, tickets, calls, visitors (booleans)' },
520
+ ivrBotAlerts: { type: 'boolean', description: 'Alert for inbound IVR / phone bot chats (default off)' },
521
+ },
522
+ },
523
+ },
524
+ },
525
+ },
526
+ {
527
+ name: 'signature_policy_get',
528
+ description: "Get the company email signature policy for the calling admin's site: the company-wide row (teamId 0), any per-team overrides, and the suggested legal disclaimer text. Modes: locked (users cannot use personal signatures), company-default (company signature is the default, personal allowed), personal-only (the default when nothing is set). The legal disclaimer is off unless turned on. Admin only.",
529
+ inputSchema: { type: 'object', properties: {}, required: [] },
530
+ },
531
+ {
532
+ name: 'signature_policy_set',
533
+ description: "Update the company email signature policy (admin only). Only the fields passed are changed; the current row is read first and merged. teamId 0 (default) is the company-wide policy; a team id sets a per-team override. Pass mode 'default' or null to clear a value back to following the company-wide setting. Use signature_policy_get first.",
534
+ inputSchema: {
535
+ type: 'object',
536
+ properties: {
537
+ teamId: { type: 'integer', description: '0 = company-wide (default), otherwise the team id for a per-team override' },
538
+ mode: { type: 'string', enum: ['locked', 'company-default', 'personal-only', 'default'], description: 'locked | company-default | personal-only | default (follow company-wide)' },
539
+ corporateSignature: { type: ['string', 'null'], description: 'Company signature HTML, max 4000 characters. Empty or null clears it.' },
540
+ disclaimerEnabled: { type: ['boolean', 'null'], description: 'Turn the legal disclaimer on or off. null follows the company-wide choice. Off by default.' },
541
+ disclaimerText: { type: ['string', 'null'], description: 'Legal disclaimer text, max 2000 characters. Empty or null clears it.' },
511
542
  },
512
543
  },
513
544
  },
@@ -1058,6 +1089,18 @@ const TOOLS = [
1058
1089
  required: ['id', 'enabled'],
1059
1090
  },
1060
1091
  },
1092
+ {
1093
+ name: 'workflow_run_now',
1094
+ description: 'Queue an enabled workflow to run now, optionally seeded with a shallow JSON context object (max 50 keys, 4KB) that becomes the run\'s workflow attributes.',
1095
+ inputSchema: {
1096
+ type: 'object',
1097
+ properties: {
1098
+ id: { type: 'number', description: 'Workflow ID' },
1099
+ context: { type: 'object', description: 'Optional shallow object of values to seed the run, e.g. {"customerId":"123"}' },
1100
+ },
1101
+ required: ['id'],
1102
+ },
1103
+ },
1061
1104
  {
1062
1105
  name: 'workflow_create',
1063
1106
  description: 'Create a new workflow with optional pre-built nodes. Use this to build a workflow for the user from a description. Pass initialNodesBlob as a JSON string of the nodes object (keys are string IDs "1","2","3"..., values are node objects). For integration AI workflows, use 3 nodes: trigger (type:"trigger", trigger:"conversation_opened", overrideRouting:true, channels:[], triggerExpression:null), sendMessage (type:"action", actionType:"sendMessage", text:"greeting", child:3), and the AI node (type:"action", actionType:"dynamicsAI"|"netsuiteAI"|"hubSpotAI"|"shopifyAI"|"bigCommerceAI"|"woocommerceAI", configId fields null, aiConfigId:null, mcpServerIds:[]). The workflow is created disabled — activate it separately with workflow_toggle.',
@@ -1087,6 +1130,8 @@ Key node shapes:
1087
1130
  propName:"myAnswerVar" } // variable that stores the result as JSON array string
1088
1131
  • condition: { type:"action", actionType:"condition", conditionOptions:[...] }
1089
1132
  • transferToAI: { type:"action", actionType:"transferToAI", aiConfigId:N }
1133
+ • sendSurvey: { type:"action", actionType:"sendSurvey", sendSurveyId:N, surveyDeliveryMode:"conversational"|"form", closeAfterSurvey:true|false, child:N }
1134
+ closeAfterSurvey (optional, default false): when true the chat closes once the visitor completes the survey (use for Unavailable/after-hours surveys that replace a live chat). Omit to keep the old behaviour.
1090
1135
  AI follow-up suggestions ("AI mode"): add suggestFollowUps:true|false (omit = use the account default, set via ai_followups_site_set), suggestFollowUpCount:2-4, suggestFollowUpInstruction:"<=300 chars". After each web-chat bot reply the bot offers short tappable follow-up replies; typing stays available.
1091
1136
 
1092
1137
  For multiSelect dynamic options: the variable named in dynamicOptionsVariable must contain a JSON array like [{"label":"Red","value":"red"},{"label":"Blue","value":"blue"}]. Populate it via an HTTP Request node before the ask node.`,
@@ -1471,6 +1516,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1471
1516
  required: ['id'],
1472
1517
  },
1473
1518
  },
1519
+ {
1520
+ name: 'copilot_studio_ask',
1521
+ description: 'Live diagnostic: ask a connected Copilot Studio / Azure AI agent a real question and classify the reply as answered, vendor_limit (usage limit / unavailable) or no_answer (fallback topic). Use to prove the agent actually answers, not just that credentials work.',
1522
+ inputSchema: {
1523
+ type: 'object',
1524
+ properties: {
1525
+ id: { type: 'number', description: 'Config ID from copilot_studio_list' },
1526
+ question: { type: 'string', description: 'A real data-bound question for the agent' },
1527
+ },
1528
+ required: ['id', 'question'],
1529
+ },
1530
+ },
1474
1531
 
1475
1532
  // ── Monday.com field mapping / CRM search ────────────────────────────────
1476
1533
  {
@@ -1661,6 +1718,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1661
1718
  welcomeMessage: { type: 'string', description: 'Sent to the Teams user when their message opens a new conversation. Use {{user.name}} for their display name. Max 1000 characters.' },
1662
1719
  teamId: { type: 'number', description: 'Route new conversations to this team id (must be a real, non-deleted team on this site)' },
1663
1720
  clearTeamId: { type: 'boolean', description: 'Clear the team override and fall back to the site default team' },
1721
+ prechatEnabled: { type: 'boolean', description: 'Ask the Teams user prechat questions before a conversation opens' },
1722
+ prechatFields: { type: 'array', description: 'Replaces the prechat questions (max 6). Key "email" is validated as an email.', items: { type: 'object', properties: { key: { type: 'string' }, label: { type: 'string' }, required: { type: 'boolean' } }, required: ['key', 'label'] } },
1723
+ quickReplies: { type: 'array', description: 'Replaces the quick-reply buttons (max 6, 40 chars each); empty array clears them', items: { type: 'string' } },
1724
+ postChatEnabled:{ type: 'boolean', description: 'Send postChatMessage when an agent closes the conversation' },
1725
+ postChatMessage:{ type: 'string', description: 'Post-chat text, {{user.name}} = first name, max 1000 chars; empty string clears it' },
1664
1726
  },
1665
1727
  required: [],
1666
1728
  },
@@ -2381,6 +2443,24 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2381
2443
  required: ['siteId', 'indexName'],
2382
2444
  },
2383
2445
  },
2446
+ {
2447
+ name: 'index_set_profile',
2448
+ description: 'Velaro staff only. Set the ingestion profile of ONE named index on ANY site (chunk strategy, chunk size/overlap, purpose, search size, excludeFromAllSources). Apply-if-present: only fields you pass change. Changing the profile versions the content hash so the next ingest re-chunks. Wraps PUT KnowledgeSetup/Staff/{siteId}/index-profile (velaro-messaging).',
2449
+ inputSchema: {
2450
+ type: 'object',
2451
+ properties: {
2452
+ siteId: { type: 'number', description: 'Target site ID' },
2453
+ indexName: { type: 'string', description: 'Index name on that site, e.g. "100140-troubleshooting"' },
2454
+ chunkStrategy: { type: 'string', enum: ['procedure', 'section'], description: 'Chunk strategy' },
2455
+ chunkSize: { type: 'number', description: 'Chunk size' },
2456
+ chunkOverlap: { type: 'number', description: 'Chunk overlap' },
2457
+ purpose: { type: 'string', enum: ['general', 'troubleshooting', 'catalog'], description: 'Index purpose' },
2458
+ searchSize: { type: 'number', description: 'Results per search' },
2459
+ excludeFromAllSources: { type: 'boolean', description: 'Keep out of the ["*"] all-sources search' },
2460
+ },
2461
+ required: ['siteId', 'indexName'],
2462
+ },
2463
+ },
2384
2464
  {
2385
2465
  name: 'uploaded_file_reindex',
2386
2466
  description: 'Re-index an already-uploaded AI training file from blob storage — use when a file shows 0 chunks (e.g. PDF uploaded before PDF support was added). Reads from blob and re-runs full ingestion. Use index_status after ~30s to verify chunks appear.',
@@ -2626,6 +2706,29 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2626
2706
  required: ['startDate', 'endDate'],
2627
2707
  },
2628
2708
  },
2709
+ {
2710
+ name: 'report_performance_by_country_get',
2711
+ description: 'Performance by Country report (US vs Canada vs Europe vs Other): engagements (handled, missed, abandoned), performance (first response, handle time, bot containment, transfers, service level) and scores (CSAT, sentiment) per country bucket and per agent. attribution: visitor (visitor GeoIP country, default) | agent (country on the agent profile) | group (the site saved group-to-bucket map).',
2712
+ inputSchema: {
2713
+ type: 'object',
2714
+ properties: {
2715
+ startDate: { type: 'string', description: 'ISO 8601 start. Default: 30 days ago' },
2716
+ endDate: { type: 'string', description: 'ISO 8601 end. Default: now' },
2717
+ attribution: { type: 'string', enum: ['visitor', 'agent', 'group'], description: 'Default: visitor' },
2718
+ channels: { type: 'array', items: { type: 'string' }, description: 'Optional ConversationSource filter, e.g. ["Web","TwilioSms"]. Default: all channels' },
2719
+ },
2720
+ },
2721
+ },
2722
+ {
2723
+ name: 'report_performance_by_country_set_groups',
2724
+ description: 'Read or set the per-site mapping of Velaro groups (teams) to Performance by Country buckets. With no map, returns the site groups and the current mapping. With map ({"<teamId>": "US|Canada|Europe|any label"}) it REPLACES the mapping; unmapped groups report as Other. Only the site own groups are kept.',
2725
+ inputSchema: {
2726
+ type: 'object',
2727
+ properties: {
2728
+ map: { type: 'object', description: 'teamId to bucket label. Omit to just read.', additionalProperties: { type: 'string' } },
2729
+ },
2730
+ },
2731
+ },
2629
2732
  {
2630
2733
  name: 'report_campaign_performance',
2631
2734
  description: 'Email campaign performance: open rate, click rate, delivery rate, complaint rate per campaign + aggregate KPIs.',
@@ -2906,7 +3009,9 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2906
3009
  platform: { type: 'string', enum: ['v10', 'v20', 'admin'], description: 'Filter by platform' },
2907
3010
  environment: { type: 'string', enum: ['Production', 'Staging', 'Development'] },
2908
3011
  correlationId: { type: 'string', description: 'Exact correlation/trace ID filter' },
2909
- last: { type: 'string', description: 'Time window: 30m, 2h, 1d (default: 2h)' },
3012
+ last: { type: 'string', description: 'Time window ending at `to`/now: 30m, 2h, 1d (default: 2h)' },
3013
+ from: { type: 'string', description: 'Window start: ISO timestamp (2026-10-20T00:00:00Z) or relative (3h). Overrides `last`.' },
3014
+ to: { type: 'string', description: 'Window end: ISO timestamp or relative (1h = 1h ago). Default: now.' },
2910
3015
  take: { type: 'number', description: 'Max results (default: 50, max: 500)' },
2911
3016
  regex: { type: 'boolean', description: 'Treat q/logger as a regex pattern (default: false)' },
2912
3017
  },
@@ -3355,11 +3460,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3355
3460
  },
3356
3461
  {
3357
3462
  name: 'enterprise_reports',
3358
- description: 'Enterprise Reports package (enterprise-internal, default OFF; every report returns 403 when the site does not hold that package item). report: program_breakdown (compare websites, teams, platforms, devices or entry pages: volume, missed, transfers, wait, first reply, QA score and coverage, sentiment), agent_scorecard (per agent by name: resolution, transfers, handle time, AI score, supervisor-reviewed score, sentiment impact), qa_coverage (share of resolved chats AI-scored and supervisor-reviewed by week and program), coaching_queue (low-scoring chats nobody reviewed, per agent), sentiment_breakdown (by website, team, agent or topic with change vs prior period), topic_radar (topics by volume, growth, transfer rate), weekly_brief (preview of the Monday brief sections), weekly_brief_send_test (emails this week\'s brief to the signed-in user). Scoped to the caller site.',
3463
+ description: 'Enterprise Reports package (enterprise-internal, default OFF; every report returns 403 when the site does not hold that package item). report: program_breakdown (compare websites, teams, platforms, devices or entry pages: volume, missed, transfers, wait, first reply, QA score and coverage, sentiment), agent_scorecard (per agent by name: resolution, transfers, handle time, AI score, supervisor-reviewed score, sentiment impact), qa_coverage (share of resolved chats AI-scored and supervisor-reviewed by week and program), coaching_queue (low-scoring chats nobody reviewed, per agent), sentiment_breakdown (by website, team, agent or topic with change vs prior period), topic_radar (topics by volume, growth, transfer rate), weekly_brief (preview of the Monday brief sections), weekly_brief_send_test (emails this week\'s brief to the signed-in user), year_in_review (builds the 12-month Year in review document in the background: from/to yyyy-MM-dd inclusive, default the last 12 whole Pacific months, returns a build id; call again with buildId to get status and the download link). Scoped to the caller site.',
3359
3464
  inputSchema: {
3360
3465
  type: 'object',
3361
3466
  properties: {
3362
- report: { type: 'string', enum: ['program_breakdown', 'agent_scorecard', 'qa_coverage', 'coaching_queue', 'sentiment_breakdown', 'topic_radar', 'weekly_brief', 'weekly_brief_send_test', 'repeat_contact', 'demand_coverage', 'bot_containment', 'effort_index', 'promise_ledger', 'promise_dismiss'] },
3467
+ report: { type: 'string', enum: ['program_breakdown', 'agent_scorecard', 'qa_coverage', 'coaching_queue', 'sentiment_breakdown', 'topic_radar', 'weekly_brief', 'weekly_brief_send_test', 'repeat_contact', 'demand_coverage', 'bot_containment', 'effort_index', 'promise_ledger', 'promise_dismiss', 'new_agent_ramp', 'year_in_review'] },
3363
3468
  from: { type: 'string', description: 'yyyy-MM-dd start (default: 30 days ago).' },
3364
3469
  to: { type: 'string', description: 'yyyy-MM-dd end, inclusive (default: today).' },
3365
3470
  groupBy: { type: 'string', description: 'program_breakdown: website|team|platform|device|entrypage. sentiment_breakdown: website|team|agent|theme.' },
@@ -3377,22 +3482,24 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3377
3482
  lowScore: { type: 'number', description: 'bot_containment: QA score below which a handed-off chat counts as regret (1-100, default 60).' },
3378
3483
  includeDemo: { type: 'boolean', description: 'true = show only the seeded sample (demo) data. Default false.' },
3379
3484
  promiseId: { type: 'string', description: 'promise_dismiss: the promise id to dismiss (managers only).' },
3485
+ buildId: { type: 'string', description: 'year_in_review: the build id returned by a start call; when given, returns that build\'s status (queued|building|completed|failed) and download link instead of starting a new build.' },
3380
3486
  },
3381
3487
  required: ['report'],
3382
3488
  },
3383
3489
  },
3384
3490
  {
3385
3491
  name: 'report_backfill',
3386
- description: 'Backfill report data for the caller site. Preview by default (reports "would fill N", writes nothing); apply=true writes. Idempotent. type "csat" fills missing survey ratings (SurveySubmission.Rating) from existing data; it does NOT touch ticket CSAT. Site managers only; scoped to the caller site.',
3492
+ description: 'Backfill report data for the caller site. Preview by default (reports "would fill N", writes nothing); apply=true writes. Idempotent. type "csat" fills missing survey ratings (SurveySubmission.Rating) from existing data; it does NOT touch ticket CSAT. type "csat-demo" adds tagged demo CSAT assigned to the demo agents (allow-listed demo sites only); type "csat-demo-undo" over the same from/to deletes exactly those tagged rows and nothing else. type "history-classification-sample" tags 20 recent conversations and reports the line-of-business and intent mix it found (the proposed tags); type "history-classification" then tags and sentiment-scores past resolved conversations in the range (up to 100 per run, run again to continue) so Enterprise Reports and Year in Review show classified history instead of Unclassified / Insufficient data. For both history types, apply=true is QUEUED (the server returns 202 with a runId and runs it in the background; one run per site at a time, a repeat call returns the active run); pass status=true to read the latest run (queued, running, completed with result, or failed). Site managers only; scoped to the caller site.',
3387
3493
  inputSchema: {
3388
3494
  type: 'object',
3389
3495
  properties: {
3390
- type: { type: 'string', description: 'Backfill type, e.g. csat.' },
3391
- from: { type: 'string', description: 'yyyy-MM-dd start.' },
3392
- to: { type: 'string', description: 'yyyy-MM-dd end.' },
3393
- apply: { type: 'boolean', description: 'true = write. Default false (preview only).' },
3496
+ type: { type: 'string', description: 'Backfill type: csat, csat-demo, csat-demo-undo, history-classification-sample or history-classification.' },
3497
+ from: { type: 'string', description: 'yyyy-MM-dd start (required unless status=true).' },
3498
+ to: { type: 'string', description: 'yyyy-MM-dd end (required unless status=true).' },
3499
+ apply: { type: 'boolean', description: 'true = write. Default false (preview only). For history types this queues a background run.' },
3500
+ status: { type: 'boolean', description: 'true = return the latest queued/running/finished apply run for this type instead of starting anything.' },
3394
3501
  },
3395
- required: ['type', 'from', 'to'],
3502
+ required: ['type'],
3396
3503
  },
3397
3504
  },
3398
3505
  // -- Staff: schedule adherence by site + role (SupportTools-style: explicit siteId, never auth-derived) --
@@ -3560,6 +3667,17 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3560
3667
  required: ['siteId'],
3561
3668
  },
3562
3669
  },
3670
+ {
3671
+ name: 'avatar_session_readiness',
3672
+ description: 'Velaro staff diagnostic: runs the real avatar session gates for a site (plan, config, provider key, billing authority, concurrency) without claiming a slot or calling the provider, so it costs nothing. Use when a visitor avatar session returns 503 or will not start. Calls velaro-messaging GET /avatar/diagnostics/session-readiness (staff only).',
3673
+ inputSchema: {
3674
+ type: 'object',
3675
+ properties: {
3676
+ siteId: { type: 'number', description: 'Site ID to check' },
3677
+ },
3678
+ required: ['siteId'],
3679
+ },
3680
+ },
3563
3681
  {
3564
3682
  name: 'avatar_set_config',
3565
3683
  description: 'Set up or update the AI Video Avatar for a customer site (partial update — only send the fields you want to change). Velaro admin only.',
@@ -3572,11 +3690,13 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3572
3690
  avatarId: { type: 'string', description: 'Avatar ID. For provider "velaro" this is a Velaro Avatar library slug. Other providers: their own face/avatar ID. Omit for provider default.' },
3573
3691
  voiceId: { type: 'string', description: 'ElevenLabs voice ID override. Omit to use the site default.' },
3574
3692
  showCallMeOption: { type: 'boolean', description: 'Show the "Call me instead" option (requires EnableClickToCall).' },
3693
+ enabledOnAgentTransfer: { type: 'boolean', description: 'Keep the avatar running after a human agent joins the conversation (default off).' },
3575
3694
  callbackConfirmWindowMinutes: { type: 'number', description: 'Minutes to confirm a callback request before it expires. Default 10.' },
3576
3695
  displayName: { type: 'string', description: 'Visitor-facing avatar name shown on the badge (e.g. "Moshky"). Empty string resets to "AI".' },
3577
3696
  showBadge: { type: 'boolean', description: 'Show the name badge over the avatar video. Default true.' },
3578
3697
  sizePx: { type: 'number', description: 'Max avatar panel width in px (160-640, square). Default 360.' },
3579
3698
  allowVisitorCollapse: { type: 'boolean', description: 'Let visitors hide or collapse the avatar panel (up/down arrow and X). Default true.' },
3699
+ heyGenEnabled: { type: 'boolean', description: 'Grant or revoke the HeyGen provider entitlement (EnableAvatarHeyGen) for this site. Off by default; HeyGen is hidden from the customer UI until granted.' },
3580
3700
  motionLevel: { type: 'string', enum: ['', 'off', 'subtle', 'natural', 'expressive'], description: 'Body motion preset layered over the lip-sync (shoulders, breathing, head, brows). Empty string resets to the platform default (natural). Visitors with reduced-motion enabled always get a still avatar.' },
3581
3701
  motionIntensityPercent: { type: 'integer', minimum: 25, maximum: 150, description: 'Body motion strength, whole percent 25-150 (default 100); multiplies every motion amplitude.' },
3582
3702
  maxAvatarMinutesPerMonth: { type: 'number', description: 'Monthly avatar video minutes cap for the site. Whole number 1-100000; never unlimited (0/negative rejected).' },
@@ -3674,6 +3794,31 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3674
3794
  description: 'Reset ALL group and workflow overrides for the calling site back to the site default.',
3675
3795
  inputSchema: { type: 'object', properties: {} },
3676
3796
  },
3797
+ {
3798
+ name: 'avatar_set_workflow_switch',
3799
+ description: 'Switch the AI avatar on, off, or back to the site rules for ONE workflow of the calling site. mode "on" shows the avatar, "off" hides it, "rules" clears the switch so the site rules decide. Only the switch changes; the rest of the workflow override is kept. Velaro admin or site auth.',
3800
+ inputSchema: {
3801
+ type: 'object',
3802
+ properties: {
3803
+ workflowId: { type: 'number', description: 'Workflow id (must belong to the calling site).' },
3804
+ mode: { type: 'string', enum: ['on', 'off', 'rules'], description: 'on | off | rules' },
3805
+ },
3806
+ required: ['workflowId', 'mode'],
3807
+ },
3808
+ },
3809
+ {
3810
+ name: 'avatar_bulk_set_switch',
3811
+ description: 'Set the AI avatar switch (on | off | rules) for many workflows and/or teams of the calling site in one call. Select rows with workflowIds and/or groupIds (at least one; max 500 rows). Each changed row is audited. Velaro admin or site auth.',
3812
+ inputSchema: {
3813
+ type: 'object',
3814
+ properties: {
3815
+ mode: { type: 'string', enum: ['on', 'off', 'rules'], description: 'on | off | rules' },
3816
+ workflowIds: { type: 'array', items: { type: 'number' }, description: 'Workflow ids of the calling site.' },
3817
+ groupIds: { type: 'array', items: { type: 'number' }, description: 'Team (group) ids of the calling site.' },
3818
+ },
3819
+ required: ['mode'],
3820
+ },
3821
+ },
3677
3822
 
3678
3823
  // ── Calendly integration management (superadmin) ──────────────────────────
3679
3824
  {
@@ -4027,6 +4172,26 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4027
4172
  required: ['shop'],
4028
4173
  },
4029
4174
  },
4175
+ {
4176
+ name: 'shopify_get_write_policy',
4177
+ description: 'Get the Shopify write-tier policy for the site: enableShopifyWrite (master toggle for the 11 Shopify write skills; read skills are unaffected), requireConfirmation, and the effective caps maxRefundAmount, maxDiscountPercent, maxDiscountFixedAmount (defaults filled in: 250 / 20 / 50). Site is resolved server-side from the caller\'s auth context. Lives in velaro-messaging (GET /api/Shopify/WritePolicy) - requires VELARO_MESSAGING_API.',
4178
+ inputSchema: { type: 'object', properties: {}, required: [] },
4179
+ },
4180
+ {
4181
+ name: 'shopify_set_write_policy',
4182
+ description: 'Update the Shopify write-tier policy for the site (apply-if-present, partial: omitted fields keep their stored value). Pass null for a cap to return it to the default. enableShopifyWrite=false hides the 11 Shopify write skills (refund, discount, etc.) without touching the read skills. Validated server-side. Lives in velaro-messaging (PUT /api/Shopify/WritePolicy) - requires VELARO_MESSAGING_API.',
4183
+ inputSchema: {
4184
+ type: 'object',
4185
+ properties: {
4186
+ enableShopifyWrite: { type: 'boolean', description: 'Master toggle for the Shopify write skills.' },
4187
+ requireConfirmation: { type: 'boolean', description: 'Require customer confirmation before a write skill executes.' },
4188
+ maxRefundAmount: { type: ['number', 'null'], description: 'Max refund amount per call. null = default (250).' },
4189
+ maxDiscountPercent: { type: ['number', 'null'], description: 'Max percentage discount. null = default (20).' },
4190
+ maxDiscountFixedAmount: { type: ['number', 'null'], description: 'Max fixed-amount discount. null = default (50).' },
4191
+ },
4192
+ required: [],
4193
+ },
4194
+ },
4030
4195
  {
4031
4196
  name: 'appointments_manage',
4032
4197
  description: 'Velaro Appointments — reschedules or cancels an existing appointment that was booked via appointments_book. Requires the provider slug and event_id from the original booking. Set intent="reschedule" with a new start (and optional duration) to move it, or intent="cancel" to remove it. Cancel is destructive and is additionally gated by the EnableDestructiveAiSkills subscription flag. Marketing page: /appointments.',
@@ -4219,8 +4384,9 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4219
4384
  properties: {
4220
4385
  storeUrl: { type: 'string', description: 'Magento store base URL, e.g. "https://mystore.com"' },
4221
4386
  accessToken: { type: 'string', description: 'Magento REST API bearer token (Admin Integration token)' },
4387
+ configId: { type: 'number', description: 'Test an already-connected store with its stored token instead (storeUrl/accessToken then not needed)' },
4222
4388
  },
4223
- required: ['storeUrl', 'accessToken'],
4389
+ required: [],
4224
4390
  },
4225
4391
  },
4226
4392
  {
@@ -4295,6 +4461,18 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4295
4461
  },
4296
4462
 
4297
4463
  // ── Business actions + SAP (one definition, many surfaces) ─────────────────────
4464
+ {
4465
+ name: 'salesforce_diagnose',
4466
+ description: 'Run the Salesforce live diagnostic for a site: configuration completeness, OAuth token acquisition and, when deep, a read-only SOQL probe per object the skills use (contacts, leads, accounts, tasks, notes, email history, opportunities, cases, products, orders) with its package tier (Core/Sales/Service/Commerce). Never writes; write capabilities are never reported as verified; secrets are never returned. Calls messaging GET diagnostics/test-salesforce?siteId=<id>&deep=<bool>. Velaro staff only.',
4467
+ inputSchema: {
4468
+ type: 'object',
4469
+ properties: {
4470
+ siteId: { type: 'number', description: 'Site to diagnose (e.g. 1032)' },
4471
+ deep: { type: 'boolean', description: 'Probe every object (default true). false = configuration + token only.' },
4472
+ },
4473
+ required: ['siteId'],
4474
+ },
4475
+ },
4298
4476
  {
4299
4477
  name: 'sap_diagnose',
4300
4478
  description: 'Run the SAP live diagnostic for a site: configuration completeness, authentication (real HTTP status) and, when deep, a per-capability read-only probe compared with what the capability matrix documents (S/4HANA on-prem/private/public, Business One, ECC via gateway). Never writes; a write capability is never reported as verified; secrets are never returned. Calls messaging GET diagnostics/test-sap?siteId=<id>&deep=<bool>. Velaro staff only.',
@@ -4403,6 +4581,35 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4403
4581
  },
4404
4582
  },
4405
4583
 
4584
+ {
4585
+ name: 'netsuite_suiteapp_download',
4586
+ description: 'Download the NetSuite screen-pop SuiteApp (SDF project zip) to a local file. With includeKey=true (default) a new crm-embed-only key is minted and baked into the zip, so NO NetSuite API Secret and no key paste is needed. With includeKey=false the zip is key-less and the admin must create a NetSuite API Secret: type ID velaro_api_key in the form (NetSuite adds the prefix, giving custsecret_velaro_api_key). Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
4587
+ inputSchema: { type: 'object', properties: { includeKey: { type: 'boolean', description: 'Default true.' }, outputPath: { type: 'string', description: 'Local file path to write (default: temp dir).' } }, required: [] },
4588
+ },
4589
+ {
4590
+ name: 'netsuite_config_delete',
4591
+ description: 'Delete a NetSuite connection (config) from the site. Removes the stored credentials; mappings/workflows referencing it will stop working. Destructive. Lives in velaro-messaging (DELETE Netsuite/Integrations/{id}) - requires VELARO_MESSAGING_API.',
4592
+ inputSchema: {
4593
+ type: 'object',
4594
+ properties: { configId: { type: 'number', description: 'NetSuite config ID (see netsuite_list_configs).' } },
4595
+ required: ['configId'],
4596
+ },
4597
+ },
4598
+ {
4599
+ name: 'netsuite_m2m_clear_legacy',
4600
+ description: 'Disconnect the old consumer-key/token credentials from a NetSuite config that already runs on a saved M2M certificate (explicit, never automatic; the connection row, mappings and workflows are untouched). Fails if M2M is not saved/active. Lives in velaro-messaging (POST NetSuite/M2M/ClearLegacy) - requires VELARO_MESSAGING_API.',
4601
+ inputSchema: {
4602
+ type: 'object',
4603
+ properties: { configId: { type: 'number', description: 'NetSuite config ID (see netsuite_list_configs).' } },
4604
+ required: ['configId'],
4605
+ },
4606
+ },
4607
+ {
4608
+ name: 'netsuite_embed_status',
4609
+ description: 'NetSuite SuiteApp call-pop (CRM embed) status for the site: is a crm_embed API key set, and the last heartbeat (when it last polled, connected or not) per key. Lives in velaro-messaging (GET api/v1/CrmEmbed/embed-status; fields hasEmbedKey, connected, lastPolledAt) - requires VELARO_MESSAGING_API.',
4610
+ inputSchema: { type: 'object', properties: {}, required: [] },
4611
+ },
4612
+
4406
4613
  {
4407
4614
  name: 'netsuite_m2m_init',
4408
4615
  description: 'Start NetSuite machine-to-machine (OAuth 2.0 Client Credentials) setup: generates the Velaro public certificate PEM to upload in NetSuite (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup) and returns the steps. Omit configId for a new connection (company then required). Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
@@ -4896,6 +5103,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4896
5103
  description: 'Show the Enterprise Reports package (enterprise-internal, default OFF): every item with its feature key, what it builds on, and, when siteId is given, whether it is ON for that site. Superadmin only.',
4897
5104
  inputSchema: { type: 'object', properties: { siteId: { type: 'number', description: 'Optional site ID to show which items are on' } } },
4898
5105
  },
5106
+ {
5107
+ name: 'entitlement_report_packages',
5108
+ description: 'Show the five report category packages (Conversations & Channels, Workforce & Operations, Customer Experience, AI & Intelligence, Business & Executive) nested under the top Enterprise Reports package (default OFF): every report with its package slug and entitlement key and, when siteId is given, whether each report and each whole category is ON for that site. Superadmin only.',
5109
+ inputSchema: { type: 'object', properties: { siteId: { type: 'number', description: 'Optional site ID to show which reports are on' } } },
5110
+ },
4899
5111
  {
4900
5112
  name: 'entitlement_set_override',
4901
5113
  description: 'Set a per-site feature override — enable or disable a specific feature for one site, independent of their plan. Superadmin only. Use entitlement_list_features to find valid featureKey values.',
@@ -5090,11 +5302,11 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5090
5302
  },
5091
5303
  {
5092
5304
  name: 'focus_update_triage_rules',
5093
- description: 'Enable or disable one or more of the 5 fixed AI triage labels (urgent, needs_reply, offer, fyi, waiting) for this site. Apply-if-present -- only labels included are changed. Rejects any label name outside the fixed set. Disabling all 5 fails safe back to all-enabled server-side rather than breaking triage. Optionally set triageMode: "economy" classifies obvious messages (auto-replies, clear urgency) without an AI call, lowering AI usage; "off" keeps current behavior. Unspecified fields are preserved.',
5305
+ description: 'Enable or disable one or more of the 5 fixed AI triage labels (urgent, needs_reply, offer, fyi, waiting) for this site. Apply-if-present -- only labels included are changed. Rejects any label name outside the fixed set. Disabling all 5 fails safe back to all-enabled server-side rather than breaking triage. Optionally set triageMode: "economy" classifies obvious messages (auto-replies, clear urgency) without an AI call, lowering AI usage; "off" makes this site always use the AI call (platform default is economy). Unspecified fields are preserved.',
5094
5306
  inputSchema: {
5095
5307
  type: 'object',
5096
5308
  properties: {
5097
- triageMode: { type: 'string', enum: ['off', 'economy'], description: 'Optional. "economy" skips the AI call for obvious messages; "off" (default) keeps current behavior.' },
5309
+ triageMode: { type: 'string', enum: ['off', 'economy'], description: 'Optional. "economy" skips the AI call for obvious messages; "off" makes this site always use the AI call. Unset inherits the platform default, which is economy.' },
5098
5310
  labels: {
5099
5311
  type: 'array',
5100
5312
  description: 'Labels to update.',
@@ -5200,7 +5412,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5200
5412
  type: 'object',
5201
5413
  properties: {
5202
5414
  start: { type: 'string', description: 'ISO 8601 start of range.' },
5203
- end: { type: 'string', description: 'ISO 8601 end of range. Must be at most 90 days after start.' },
5415
+ end: { type: 'string', description: 'End of range. A plain YYYY-MM-DD date includes that whole day (the server end is exclusive, so it is sent as the start of the next day); a full ISO 8601 datetime is an exact exclusive instant. Must be at most 90 days after start.' },
5204
5416
  },
5205
5417
  required: ['start', 'end'],
5206
5418
  },
@@ -5330,6 +5542,28 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5330
5542
  },
5331
5543
  },
5332
5544
 
5545
+ // -- Apple Pay display settings --
5546
+ // Backend: velaro-messaging ApplePayConfigsController (GET/POST ApplePayConfig, PUT ApplePayConfig/{id}).
5547
+ // Display + test mode only; merchant id / certificate (which affect the charge) are intentionally not settable here.
5548
+ {
5549
+ name: 'apple_pay_get_config',
5550
+ description: 'Get this site\'s Apple Pay settings: enabled, test mode (sandbox: Velaro Stripe test account, no real money, real Wallet cards decline), business name shown on the Apple Pay sheet, whether a custom payment-bubble logo is set, and whether the site uses Velaro\'s platform merchant. id is 0 when the site has no row yet.',
5551
+ inputSchema: { type: 'object', properties: {}, required: [] },
5552
+ },
5553
+ {
5554
+ name: 'apple_pay_update_config',
5555
+ description: 'Create or update this site\'s Apple Pay DISPLAY settings and test mode. Apply-if-present: omit a field to leave it unchanged. These never affect the charge (the merchant id and certificate do, and are managed by secure Velaro admin provisioning). Creates the row when the site has none.',
5556
+ inputSchema: {
5557
+ type: 'object',
5558
+ properties: {
5559
+ merchantDisplayName: { type: 'string', description: 'Business name shown on the Apple Pay sheet' },
5560
+ isTestMode: { type: 'boolean', description: 'Sandbox mode: Velaro Stripe test account, no real money moves, real Wallet cards decline' },
5561
+ bubbleLogoBase64: { type: 'string', description: 'PNG/JPEG base64 (max 500 KB) for the payment bubble. Empty string clears it back to the default image.' },
5562
+ },
5563
+ required: [],
5564
+ },
5565
+ },
5566
+
5333
5567
  // -- Recruiting Bot --
5334
5568
  // Backend: server/Velaro.Messaging/Controllers/RecruitingController.cs.
5335
5569
  // [EnableRecruitingBot] gated server-side; siteId always derives from the
@@ -6035,6 +6269,14 @@ function parseBusinessHours(input) {
6035
6269
 
6036
6270
  // ── Tool handlers ─────────────────────────────────────────────────────────────
6037
6271
 
6272
+ // Report end params are exclusive server-side: a plain YYYY-MM-DD end means "through that whole
6273
+ // day", so send the start of the next day (mirrors cli/lib/report-range.js exclusiveEndParam).
6274
+ function exclusiveEnd(raw) {
6275
+ if (raw == null || raw === '') return raw;
6276
+ const d = new Date(raw);
6277
+ if (Number.isNaN(d.getTime()) || !/^\d{4}-\d{2}-\d{2}$/.test(String(raw).trim())) return raw;
6278
+ return new Date(d.getTime() + 24 * 60 * 60 * 1000).toISOString();
6279
+ }
6038
6280
  // -- Agent inactivity chain helpers (velaro-messaging PR #1471, v20 ONLY) ------------------------
6039
6281
  // Terminal action ints are persisted by messaging (append-only enum). The API returns/accepts the NAME.
6040
6282
  const TERMINAL_ACTION_NAMES = ['Survey','CreateTicket','SurveyAndCreateTicket','Queue','OfflineMessage','NoOp','EndChat','NotifyManager','RunWorkflow','ReturnToBot'];
@@ -6165,7 +6407,9 @@ async function handleTool(name, args) {
6165
6407
  }
6166
6408
 
6167
6409
  case 'notifications_get': {
6168
- return JSON.stringify(await messagingGet('/NotificationSettings'), null, 2);
6410
+ const base = await messagingGet('/NotificationSettings');
6411
+ const routing = await messagingGet('/api/v1/alert-routing').catch(() => null);
6412
+ return JSON.stringify({ ...base, alertRouting: routing?.data ?? null }, null, 2);
6169
6413
  }
6170
6414
 
6171
6415
  case 'notifications_set': {
@@ -6179,7 +6423,47 @@ async function handleTool(name, args) {
6179
6423
  if (args[f] !== undefined) current[f] = args[f];
6180
6424
  }
6181
6425
  await messagingPost('/NotificationSettings', current);
6182
- return JSON.stringify(await messagingGet('/NotificationSettings'), null, 2);
6426
+ if (args.alertRouting && typeof args.alertRouting === 'object') {
6427
+ await messagingPut('/api/v1/alert-routing', args.alertRouting);
6428
+ }
6429
+ const routing = await messagingGet('/api/v1/alert-routing').catch(() => null);
6430
+ return JSON.stringify({ ...(await messagingGet('/NotificationSettings')), alertRouting: routing?.data ?? null }, null, 2);
6431
+ }
6432
+
6433
+ case 'signature_policy_get': {
6434
+ return JSON.stringify(await messagingGet('/Signatures/policy'), null, 2);
6435
+ }
6436
+
6437
+ case 'signature_policy_set': {
6438
+ // PUT replaces the row and silently turns an unknown mode into "follow company-wide", so validate and merge here.
6439
+ // Same rules as cli/lib/signature-policy.js (keep the two in step).
6440
+ const modes = { 'locked': 'admin_locked', 'company-default': 'company_default', 'personal-only': 'personal_only' };
6441
+ const teamId = args.teamId === undefined || args.teamId === null ? 0 : args.teamId;
6442
+ if (!Number.isInteger(teamId) || teamId < 0) throw new Error('teamId must be 0 (company-wide) or a team id');
6443
+ const data = await messagingGet('/Signatures/policy');
6444
+ const cur = (Array.isArray(data?.policies) ? data.policies : []).find((p) => p.teamId === teamId) ?? {};
6445
+ const body = {
6446
+ teamId,
6447
+ mode: cur.mode ?? null,
6448
+ corporateSignature: cur.corporateSignature || null,
6449
+ disclaimerEnabled: cur.disclaimerEnabled ?? null,
6450
+ disclaimerText: cur.disclaimerText || null,
6451
+ };
6452
+ if (args.mode !== undefined) {
6453
+ if (args.mode === null || args.mode === 'default') body.mode = null;
6454
+ else if (modes[args.mode]) body.mode = modes[args.mode];
6455
+ else throw new Error('mode must be locked, company-default, personal-only or default');
6456
+ }
6457
+ if (args.corporateSignature !== undefined) body.corporateSignature = args.corporateSignature || null;
6458
+ if (args.disclaimerEnabled !== undefined) {
6459
+ if (args.disclaimerEnabled !== null && typeof args.disclaimerEnabled !== 'boolean') throw new Error('disclaimerEnabled must be true, false or null');
6460
+ body.disclaimerEnabled = args.disclaimerEnabled;
6461
+ }
6462
+ if (args.disclaimerText !== undefined) body.disclaimerText = args.disclaimerText || null;
6463
+ if (body.corporateSignature && body.corporateSignature.length > 4000) throw new Error('corporateSignature is limited to 4000 characters');
6464
+ if (body.disclaimerText && body.disclaimerText.length > 2000) throw new Error('disclaimerText is limited to 2000 characters');
6465
+ await messagingPut('/Signatures/policy', body);
6466
+ return JSON.stringify(await messagingGet('/Signatures/policy'), null, 2);
6183
6467
  }
6184
6468
 
6185
6469
  case 'admin_startup_diagnostics': {
@@ -6468,7 +6752,7 @@ async function handleTool(name, args) {
6468
6752
  case 'chat_intelligence_themes': {
6469
6753
  const qs = new URLSearchParams();
6470
6754
  if (args.startDate) qs.set('startDate', args.startDate);
6471
- if (args.endDate) qs.set('endDate', args.endDate);
6755
+ if (args.endDate) qs.set('endDate', exclusiveEnd(args.endDate));
6472
6756
  qs.set('groupBy', args.groupBy ?? 'month');
6473
6757
  if (args.deploymentId != null) qs.set('deploymentId', String(args.deploymentId));
6474
6758
  if (args.teamId != null) qs.set('teamId', String(args.teamId));
@@ -6562,6 +6846,8 @@ async function handleTool(name, args) {
6562
6846
 
6563
6847
  case 'workflow_toggle':
6564
6848
  return await rpc('workflow_toggle', args);
6849
+ case 'workflow_run_now':
6850
+ return await rpc('workflow_run_now', args);
6565
6851
 
6566
6852
  case 'workflow_create':
6567
6853
  return await rpc('workflow_create', args);
@@ -6941,6 +7227,13 @@ async function handleTool(name, args) {
6941
7227
  return `Connection test FAILED for agent ${args.id}: ${result?.error ?? 'unknown error'}`;
6942
7228
  }
6943
7229
 
7230
+ case 'copilot_studio_ask': {
7231
+ if (!args.id || !args.question) return 'id and question are required.';
7232
+ const r = await api('POST', '/CopilotStudio/message', { configId: args.id, message: args.question });
7233
+ return `kind: ${r?.kind}
7234
+ reply: ${r?.reply}`;
7235
+ }
7236
+
6944
7237
  // ── Monday.com field mapping / CRM search ──────────────────────────────
6945
7238
  // Wired to MondayController.cs actions: GetBoardColumns, GetFieldMapping,
6946
7239
  // UpdateFieldMapping, SearchContacts (routes under /Monday/...).
@@ -7093,7 +7386,7 @@ async function handleTool(name, args) {
7093
7386
 
7094
7387
  case 'teams_channel_config_update': {
7095
7388
  const body = {};
7096
- for (const k of ['enabled','welcomeMessage','teamId','clearTeamId'])
7389
+ for (const k of ['enabled','welcomeMessage','teamId','clearTeamId','prechatEnabled','prechatFields','quickReplies','postChatEnabled','postChatMessage'])
7097
7390
  if (args[k] !== undefined) body[k] = args[k];
7098
7391
  return await api('PUT', '/TeamsBot/ChannelConfig', body);
7099
7392
  }
@@ -8065,6 +8358,17 @@ async function handleTool(name, args) {
8065
8358
  return `Index ready on site ${args.siteId}: ${idx?.name} (id=${idx?.id}, purpose=${idx?.purpose}, excludeFromAllSources=${!!idx?.excludeFromAllSources}).`;
8066
8359
  }
8067
8360
 
8361
+ case 'index_set_profile': {
8362
+ if (!args.siteId || !args.indexName) return 'siteId and indexName are required.';
8363
+ const body = { indexName: args.indexName };
8364
+ for (const k of ['chunkStrategy', 'chunkSize', 'chunkOverlap', 'purpose', 'searchSize', 'excludeFromAllSources']) {
8365
+ if (args[k] !== undefined) body[k] = args[k];
8366
+ }
8367
+ const idx = await messagingRequest('PUT', `/KnowledgeSetup/Staff/${encodeURIComponent(args.siteId)}/index-profile`, body);
8368
+ if (idx?.error) return `Set index profile error: ${idx.error}`;
8369
+ return `Profile updated on site ${args.siteId}: ${idx?.name} (purpose=${idx?.purpose}, strategy=${idx?.chunkStrategy}, size=${idx?.chunkSize}, overlap=${idx?.chunkOverlap}, searchSize=${idx?.searchSize}, excludeFromAllSources=${!!idx?.excludeFromAllSources}). Re-ingest to apply to existing content.`;
8370
+ }
8371
+
8068
8372
  case 'uploaded_files_list': {
8069
8373
  const files = await messagingRequest('GET', '/uploadedFiles/list/AI');
8070
8374
  if (!Array.isArray(files) || !files.length) return 'No AI training files uploaded for this site.';
@@ -8088,7 +8392,7 @@ async function handleTool(name, args) {
8088
8392
  case 'service_level_report': {
8089
8393
  const qs = [
8090
8394
  args.startDate ? `startDate=${args.startDate}` : '',
8091
- args.endDate ? `endDate=${args.endDate}` : '',
8395
+ args.endDate ? `endDate=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8092
8396
  args.granularity ? `granularity=${args.granularity}` : '',
8093
8397
  ].filter(Boolean).join('&');
8094
8398
  const r = await api('GET', `/ServiceLevel${qs ? '?' + qs : ''}`);
@@ -8138,7 +8442,7 @@ async function handleTool(name, args) {
8138
8442
  case 'report_agent_utilization': {
8139
8443
  const qs = [
8140
8444
  args.startDate ? `startDate=${args.startDate}` : '',
8141
- args.endDate ? `endDate=${args.endDate}` : '',
8445
+ args.endDate ? `endDate=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8142
8446
  ].filter(Boolean).join('&');
8143
8447
  const rows = await api('GET', `/Reports/AgentUtilization${qs ? '?' + qs : ''}`);
8144
8448
  if (!Array.isArray(rows) || !rows.length) return 'No agent utilization data for this period.';
@@ -8259,10 +8563,36 @@ async function handleTool(name, args) {
8259
8563
  return `Export queued (id ${result.exportId}). ${result.message}`;
8260
8564
  }
8261
8565
 
8566
+ case 'report_performance_by_country_get': {
8567
+ const end = args.endDate ? new Date(args.endDate) : new Date();
8568
+ const start = args.startDate ? new Date(args.startDate) : new Date(end.getTime() - 30 * 86400000);
8569
+ if (isNaN(start) || isNaN(end)) return 'Error: startDate/endDate must be valid ISO 8601 dates.';
8570
+ const r = await messagingPost('/reports/performance-by-country/query', {
8571
+ start: start.toISOString(), end: end.toISOString(),
8572
+ attribution: args.attribution || 'visitor', channels: Array.isArray(args.channels) ? args.channels : [],
8573
+ });
8574
+ const f = (v, s = '') => (v == null ? '-' : `${v}${s}`);
8575
+ const row = x => `${(x.agentName || x.bucket).padEnd(24)} eng ${x.engagements} | handled ${x.handled} | missed ${x.missed} | abandoned ${x.abandoned} | bot containment ${f(x.botContainmentPct, '%')} | transfers ${x.transfersToAgent} | 1st resp ${f(x.avgFirstResponseSeconds, 's')} | handle ${f(x.avgHandleTimeMinutes, 'm')} | SL ${f(x.serviceLevelPct, '%')} | CSAT ${f(x.csatAvg)} (${x.csatCount}) | positive ${f(x.sentimentPositivePct, '%')}`;
8576
+ return [`=== Performance by Country (${r.attribution}) ===`, ...(r.buckets || []).map(row), '', 'By agent:',
8577
+ ...((r.agents || []).map(a => row(a) + (a.agentCountryNotSet ? ' [agent country not set]' : '')))].join('\n');
8578
+ }
8579
+
8580
+ case 'report_performance_by_country_set_groups': {
8581
+ const groups = await messagingGet('/reports/performance-by-country/groups');
8582
+ let current;
8583
+ if (args.map && typeof args.map === 'object') {
8584
+ current = await messagingPut('/reports/performance-by-country/group-map', { map: args.map });
8585
+ } else {
8586
+ current = await messagingGet('/reports/performance-by-country/group-map');
8587
+ }
8588
+ const map = current?.map ?? {};
8589
+ return ['Groups:', ...(Array.isArray(groups) ? groups : []).map(g => ` ${g.id} ${g.name} -> ${map[g.id] ?? '(unmapped, reports as Other)'}`)].join('\n');
8590
+ }
8591
+
8262
8592
  case 'call_center_report': {
8263
8593
  const qs = [
8264
8594
  args.startDate ? `start=${args.startDate}` : '',
8265
- args.endDate ? `end=${args.endDate}` : '',
8595
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8266
8596
  ].filter(Boolean).join('&');
8267
8597
  const r = await api('GET', `/CallCenterAnalytics/Report${qs ? '?' + qs : ''}`);
8268
8598
  const v = r.volume ?? {};
@@ -8300,7 +8630,7 @@ async function handleTool(name, args) {
8300
8630
  case 'call_center_team_breakdown': {
8301
8631
  const qs = [
8302
8632
  args.startDate ? `start=${args.startDate}` : '',
8303
- args.endDate ? `end=${args.endDate}` : '',
8633
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8304
8634
  args.slaThresholdSeconds ? `slaThresholdSeconds=${args.slaThresholdSeconds}` : '',
8305
8635
  ].filter(Boolean).join('&');
8306
8636
  let r;
@@ -8323,7 +8653,7 @@ async function handleTool(name, args) {
8323
8653
  case 'call_center_channel_breakdown': {
8324
8654
  const qs = [
8325
8655
  args.startDate ? `start=${args.startDate}` : '',
8326
- args.endDate ? `end=${args.endDate}` : '',
8656
+ args.endDate ? `end=${encodeURIComponent(exclusiveEnd(args.endDate))}` : '',
8327
8657
  args.slaThresholdSeconds ? `slaThresholdSeconds=${args.slaThresholdSeconds}` : '',
8328
8658
  ].filter(Boolean).join('&');
8329
8659
  let r;
@@ -8830,23 +9160,13 @@ async function handleTool(name, args) {
8830
9160
  }
8831
9161
 
8832
9162
  case 'velaro_logs_search': {
8833
- const from = new Date(Date.now() - parseDuration(args.last));
8834
- const params = new URLSearchParams({ from: from.toISOString(), take: String(args.take ?? 50) });
8835
- if (args.q) params.set('q', args.q);
8836
- if (args.level) params.set('level', args.level.toUpperCase());
8837
- if (args.logger) params.set('logger', args.logger);
8838
- if (args.integration) params.set('integration', args.integration);
8839
- if (args.siteId) params.set('siteId', String(args.siteId));
8840
- if (args.source) params.set('source', args.source);
8841
- if (args.platform) params.set('platform', args.platform);
8842
- if (args.environment) params.set('environment', args.environment);
8843
- if (args.correlationId) params.set('correlationId', args.correlationId);
8844
- if (args.regex) params.set('regex', 'true');
9163
+ const { from, to } = resolveLogWindow(args);
9164
+ const params = buildRawLogQuery(args, from, to);
8845
9165
  const resp = await messagingApi(`/superadmin/logs/search?${params}`, { timeoutMs: 40000 });
8846
9166
  const entries = extractLogEntries(resp);
8847
9167
  const meta = describeRawMeta(resp); // searchPath/scannedRows/truncated: a 0 or short list is never ambiguous
8848
9168
  if (!entries.length) return `No results. ${meta}`;
8849
- const lines = [`${entries.length} result(s) from ${from.toISOString().slice(0, 16)} UTC. ${meta}\n`];
9169
+ const lines = [`${entries.length} result(s) from ${from.toISOString().slice(0, 16)} to ${to.toISOString().slice(0, 16)} UTC. ${meta}\n`];
8850
9170
  for (const e of entries) {
8851
9171
  const ts = new Date(e.timestamp ?? e.LogTimestamp).toISOString().replace('T', ' ').slice(0, 19);
8852
9172
  const lvl = (e.Level ?? e.level ?? '?').padEnd(5);
@@ -9247,14 +9567,41 @@ async function handleTool(name, args) {
9247
9567
  coaching_queue: 'coaching-queue', sentiment_breakdown: 'sentiment-breakdown', topic_radar: 'topic-radar',
9248
9568
  weekly_brief: 'weekly-brief', weekly_brief_send_test: 'weekly-brief/send-test',
9249
9569
  repeat_contact: 'repeat-contact', demand_coverage: 'demand-coverage', bot_containment: 'bot-containment',
9250
- effort_index: 'effort-index', promise_ledger: 'promise-ledger',
9570
+ effort_index: 'effort-index', promise_ledger: 'promise-ledger', new_agent_ramp: 'new-agent-ramp',
9251
9571
  };
9252
9572
  if (args.report === 'promise_dismiss') {
9253
9573
  if (!args.promiseId) throw new Error('promiseId is required for promise_dismiss');
9254
9574
  return JSON.stringify(await messagingPost('/api/enterprise-reports/promise-ledger/' + encodeURIComponent(String(args.promiseId)) + '/dismiss', {}), null, 2);
9255
9575
  }
9576
+ if (args.report === 'year_in_review') {
9577
+ // POST starts a build (202 {id}); GET .../{id} -> {status, downloadUrl, error}. 403 when EnableEnterpriseReportsYearInReview is off.
9578
+ if (args.buildId) return JSON.stringify(await messagingGet('/api/enterprise-reports/year-in-review/' + encodeURIComponent(String(args.buildId))), null, 2);
9579
+ const zone = 'America/Los_Angeles';
9580
+ const keyRe = /^(\d{4})-(\d{2})-(\d{2})$/;
9581
+ const addDays = (k, n) => { const m = keyRe.exec(k); return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3] + n)).toISOString().slice(0, 10); };
9582
+ const offsetMs = (ms) => {
9583
+ const ps = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(ms));
9584
+ const g = (t) => Number(ps.find((p) => p.type === t).value);
9585
+ return Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - Math.floor(ms / 1000) * 1000;
9586
+ };
9587
+ const midnightIso = (k) => {
9588
+ if (!keyRe.test(k || '')) throw new Error('from/to must be yyyy-MM-dd');
9589
+ const m = keyRe.exec(k);
9590
+ const wall = Date.UTC(+m[1], +m[2] - 1, +m[3]);
9591
+ let t = wall - offsetMs(wall);
9592
+ t = wall - offsetMs(t);
9593
+ return new Date(t).toISOString();
9594
+ };
9595
+ const today = new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
9596
+ const defFrom = String(Number(today.slice(0, 4)) - 1).padStart(4, '0') + '-' + today.slice(5, 7) + '-01';
9597
+ const defTo = addDays(today.slice(0, 7) + '-01', -1);
9598
+ const f = args.from || defFrom;
9599
+ const t = args.to || defTo;
9600
+ if (f > t) throw new Error('from must be on or before to');
9601
+ return JSON.stringify(await messagingPost('/api/enterprise-reports/year-in-review', { from: midnightIso(f), to: midnightIso(addDays(t, 1)) }), null, 2);
9602
+ }
9256
9603
  const seg = ER_PATHS[args.report];
9257
- if (!seg) throw new Error('report must be one of: ' + Object.keys(ER_PATHS).join(', '));
9604
+ if (!seg) throw new Error('report must be one of: ' + Object.keys(ER_PATHS).join(', ') + ', promise_dismiss, year_in_review');
9258
9605
  if (args.report === 'weekly_brief_send_test') return JSON.stringify(await messagingPost('/api/enterprise-reports/' + seg, {}), null, 2);
9259
9606
  const qs = new URLSearchParams();
9260
9607
  for (const k of ['from', 'to', 'groupBy', 'deploymentId', 'teamId', 'threshold', 'perAgent', 'limit', 'days', 'windowDays', 'weeks', 'handleMinutes', 'maxConcurrent', 'timeZone', 'lowScore', 'includeDemo']) {
@@ -9297,8 +9644,11 @@ async function handleTool(name, args) {
9297
9644
  }
9298
9645
 
9299
9646
  case 'report_backfill': {
9647
+ if (args.status === true) return JSON.stringify(await messagingGet(`/Reports/backfill/${encodeURIComponent(args.type)}/status`), null, 2);
9648
+ if (!args.from || !args.to) throw new Error('from and to are required (yyyy-MM-dd) unless status=true');
9300
9649
  const qs = new URLSearchParams({ from: String(args.from), to: String(args.to), apply: String(args.apply === true) });
9301
9650
  const r = await messagingPost(`/Reports/backfill/${encodeURIComponent(args.type)}?${qs}`);
9651
+ if (args.apply === true && r && r.runId) return (r.alreadyRunning ? `A run is already ${r.status} for this site; not starting another.\n` : 'Backfill QUEUED (runs in the background; call again with status=true to read progress)\n') + JSON.stringify(r, null, 2);
9302
9652
  return (args.apply === true ? 'Backfill APPLIED\n' : 'Backfill PREVIEW (nothing written; pass apply=true to write)\n') + JSON.stringify(r, null, 2);
9303
9653
  }
9304
9654
 
@@ -9393,15 +9743,25 @@ async function handleTool(name, args) {
9393
9743
  ].join('\n');
9394
9744
  }
9395
9745
 
9746
+ case 'avatar_session_readiness': {
9747
+ const data = await messagingRequest('GET', `/avatar/diagnostics/session-readiness?siteId=${encodeURIComponent(args.siteId)}`);
9748
+ return JSON.stringify(data, null, 2);
9749
+ }
9750
+
9396
9751
  case 'avatar_get_config': {
9397
9752
  const data = await api('GET', `/Avatar/superadmin/status?siteId=${args.siteId}`);
9398
- if (!data.configured) return `Site ${args.siteId}: AI Video Avatar NOT configured (no saved config).`;
9753
+ let heyGenLine = 'unknown';
9754
+ try { const ent = await api('GET', `/Entitlements/${args.siteId}`); heyGenLine = ent?.features?.EnableAvatarHeyGen ? 'yes' : 'no'; } catch { /* entitlement lookup is best-effort */ }
9755
+ if (!data.configured) return `Site ${args.siteId}: AI Video Avatar NOT configured (no saved config). HeyGen entitled: ${heyGenLine}`;
9399
9756
  return [
9400
9757
  `Site ${args.siteId}: AI Video Avatar ${data.enabled ? 'ENABLED' : 'configured but DISABLED'}`,
9401
9758
  ` Provider: ${data.providerSlug}`,
9402
9759
  ` Avatar ID: ${data.avatarId || '(provider default)'}`,
9760
+ ` Face per provider: ${data.avatarIdByProvider && Object.keys(data.avatarIdByProvider).length ? JSON.stringify(data.avatarIdByProvider) : '(none saved)'} (read-only)`,
9761
+ ` Keep running on agent join: ${data.enabledOnAgentTransfer ? 'yes' : 'no'}`,
9403
9762
  ` Voice ID: ${data.voiceId || '(site default)'}`,
9404
9763
  ` Enabled: ${data.enabled}`,
9764
+ ` HeyGen entitled: ${heyGenLine}`,
9405
9765
  ` "Call me": ${data.showCallMeOption}`,
9406
9766
  ` Callback window: ${data.callbackConfirmWindowMinutes} min`,
9407
9767
  ` Display name: ${data.displayName || '(AI)'}`,
@@ -9418,12 +9778,17 @@ async function handleTool(name, args) {
9418
9778
  if (args.voiceId !== undefined) body.voiceId = args.voiceId;
9419
9779
  if (args.enabled !== undefined) body.enabled = args.enabled;
9420
9780
  if (args.showCallMeOption !== undefined) body.showCallMeOption = args.showCallMeOption;
9781
+ if (args.enabledOnAgentTransfer !== undefined) body.enabledOnAgentTransfer = args.enabledOnAgentTransfer;
9421
9782
  if (args.callbackConfirmWindowMinutes !== undefined) body.callbackConfirmWindowMinutes = args.callbackConfirmWindowMinutes;
9422
9783
  if (args.displayName !== undefined) body.displayName = args.displayName;
9423
9784
  if (args.showBadge !== undefined) body.showBadge = args.showBadge;
9424
9785
  if (args.sizePx !== undefined) body.sizePx = args.sizePx;
9425
9786
  if (args.allowVisitorCollapse !== undefined) body.allowVisitorCollapse = args.allowVisitorCollapse;
9787
+ if (args.heyGenEnabled !== undefined) {
9788
+ await api('POST', `/Entitlements/${args.siteId}/override`, { featureKey: 'EnableAvatarHeyGen', value: args.heyGenEnabled, reason: 'avatar_set_config heyGenEnabled' });
9789
+ }
9426
9790
  if (args.motionLevel !== undefined) body.motionLevel = parseAvatarMotionLevel(args.motionLevel);
9791
+ if (args.heyGenEnabled !== undefined && !Object.keys(body).length && args.motionIntensityPercent === undefined && args.maxAvatarMinutesPerMonth === undefined && args.maxAvatarTtsChars === undefined) return `Site ${args.siteId}: EnableAvatarHeyGen=${args.heyGenEnabled}`;
9427
9792
  if (args.motionIntensityPercent !== undefined) {
9428
9793
  if (!Number.isInteger(args.motionIntensityPercent) || args.motionIntensityPercent < 25 || args.motionIntensityPercent > 150) throw new Error('motionIntensityPercent must be a whole number between 25 and 150.');
9429
9794
  body.motionIntensityPercent = args.motionIntensityPercent;
@@ -9457,6 +9822,22 @@ async function handleTool(name, args) {
9457
9822
  return JSON.stringify(await messagingRequest('PUT', `/avatar/workflows/${Number(args.workflowId)}/override`, avatarOverrideBody(args)), null, 2);
9458
9823
  case 'avatar_reset_workflow':
9459
9824
  return JSON.stringify(await messagingRequest('DELETE', `/avatar/workflows/${Number(args.workflowId)}/override`), null, 2);
9825
+ case 'avatar_set_workflow_switch': {
9826
+ const mode = String(args.mode ?? '').trim().toLowerCase();
9827
+ if (!['on', 'off', 'rules'].includes(mode)) throw new Error('mode must be one of: on, off, rules');
9828
+ const id = Number(args.workflowId);
9829
+ if (!Number.isInteger(id) || id <= 0) throw new Error('workflowId must be a positive whole number');
9830
+ return JSON.stringify(await messagingRequest('PUT', `/avatar/workflows/${id}/enabled`, { mode }), null, 2);
9831
+ }
9832
+ case 'avatar_bulk_set_switch': {
9833
+ const mode = String(args.mode ?? '').trim().toLowerCase();
9834
+ if (!['on', 'off', 'rules'].includes(mode)) throw new Error('mode must be one of: on, off, rules');
9835
+ const workflowIds = (args.workflowIds ?? []).map(Number);
9836
+ const groupIds = (args.groupIds ?? []).map(Number);
9837
+ if (workflowIds.length + groupIds.length === 0) throw new Error('Pass workflowIds and/or groupIds');
9838
+ if (workflowIds.length + groupIds.length > 500) throw new Error('Too many rows in one call (max 500)');
9839
+ return JSON.stringify(await messagingRequest('PUT', '/avatar/overrides/enabled', { mode, workflowIds, groupIds }), null, 2);
9840
+ }
9460
9841
  case 'avatar_reset_all_overrides':
9461
9842
  return JSON.stringify(await messagingRequest('POST', '/avatar/group-overrides/reset-all', {}), null, 2);
9462
9843
 
@@ -9980,7 +10361,11 @@ async function handleTool(name, args) {
9980
10361
  }
9981
10362
 
9982
10363
  case 'magento_test_connection': {
9983
- const r = await api('POST', '/Magento/Configs/Test', { storeUrl: args.storeUrl, accessToken: args.accessToken });
10364
+ if (!args.configId && (!args.storeUrl || !args.accessToken)) return 'Provide configId, or both storeUrl and accessToken.';
10365
+ const r = args.configId
10366
+ ? await api('POST', `/Magento/Configs/${encodeURIComponent(args.configId)}/Test`)
10367
+ : await api('POST', '/Magento/Configs/Test', { storeUrl: args.storeUrl, accessToken: args.accessToken });
10368
+ if (r && r.valid === false) return `❌ Connection failed: ${r.error}`;
9984
10369
  return `✅ Connected to "${r.storeName}" (${r.edition} ${r.version})`;
9985
10370
  }
9986
10371
 
@@ -10046,6 +10431,13 @@ async function handleTool(name, args) {
10046
10431
  }
10047
10432
 
10048
10433
  // ── Business actions + SAP ────────────────────────────────────────────────
10434
+ case 'salesforce_diagnose': {
10435
+ if (!args.siteId) return 'siteId is required.';
10436
+ const deep = args.deep === false ? 'false' : 'true';
10437
+ const r = await messagingRequest('GET', `/diagnostics/test-salesforce?siteId=${encodeURIComponent(args.siteId)}&deep=${deep}`);
10438
+ return JSON.stringify(r, null, 2);
10439
+ }
10440
+
10049
10441
  case 'sap_diagnose': {
10050
10442
  if (!args.siteId) return 'siteId is required.';
10051
10443
  const deep = args.deep === false ? 'false' : 'true';
@@ -10134,6 +10526,37 @@ async function handleTool(name, args) {
10134
10526
  return `CTI token generated/rotated.\nDial URL: ${urls.dialUrlTemplate}\nScreen-pop URL: ${urls.screenPopUrl}`;
10135
10527
  }
10136
10528
 
10529
+ case 'netsuite_suiteapp_download': {
10530
+ const includeKey = args.includeKey !== false;
10531
+ const r = await messagingGet(`/api/v1/CrmEmbed/suiteapp-package?includeKey=${includeKey}&format=json`);
10532
+ const d = r?.data ?? r;
10533
+ if (!d?.contentBase64) throw new Error('SuiteApp package was not returned.');
10534
+ const { writeFileSync } = await import('node:fs');
10535
+ const { tmpdir } = await import('node:os');
10536
+ const out = args.outputPath || _join(tmpdir(), d.fileName || 'velaro-netsuite-screenpop-suiteapp.zip');
10537
+ writeFileSync(out, Buffer.from(d.contentBase64, 'base64'));
10538
+ return `Saved ${out}. ` + (d.keyIncluded
10539
+ ? 'Your Velaro key is already inside: no NetSuite API Secret and nothing to paste. Deploy with suitecloud (see SETUP.txt in the zip). Keep the zip private.'
10540
+ : `Key-less zip. In NetSuite create an API Secret: type ID ${d.secretFormId} (NetSuite adds the prefix, giving ${d.secretFullId}), paste your crm_embed_ key as Password.`);
10541
+ }
10542
+ case 'netsuite_config_delete': {
10543
+ await messagingDel(`/Netsuite/Integrations/${args.configId}`);
10544
+ return `Deleted NetSuite config ${args.configId} (a missing config is a no-op).`;
10545
+ }
10546
+ case 'netsuite_m2m_clear_legacy': {
10547
+ const r = await messagingPost('/NetSuite/M2M/ClearLegacy', { id: args.configId });
10548
+ return `Legacy consumer secret/token cleared for config ${r?.id ?? args.configId} (authType ${r?.authType ?? 'm2m'}).`;
10549
+ }
10550
+ case 'netsuite_embed_status': {
10551
+ const r = await messagingGet('/api/v1/CrmEmbed/embed-status');
10552
+ const d = r?.data ?? r;
10553
+ if (!d?.hasEmbedKey) return 'No embed key exists for this site yet. The SuiteApp cannot connect until one is created.';
10554
+ if (d.connected) return `SuiteApp connected. Last polled ${d.lastPolledAt}.`;
10555
+ return d.lastPolledAt
10556
+ ? `Embed key exists but the SuiteApp is not connected (last polled ${d.lastPolledAt}, outside the connected window).`
10557
+ : 'Embed key exists but the SuiteApp has not polled yet.';
10558
+ }
10559
+
10137
10560
  case 'netsuite_m2m_init': {
10138
10561
  const r = await messagingPost('/NetSuite/M2M/Init', { id: args.configId ?? null, company: args.company, displayName: args.displayName });
10139
10562
  return `Config ${r.id ?? args.configId ?? 'new'}. Upload this certificate in NetSuite (Setup > Integration > Manage Authentication > OAuth 2.0 Client Credentials (M2M) Setup):\n${r.certificatePem ?? '(none returned)'}\n${Array.isArray(r.steps) ? r.steps.map((x, i) => `${i + 1}. ${x}`).join('\n') : ''}`;
@@ -10182,6 +10605,20 @@ async function handleTool(name, args) {
10182
10605
  return `${result.success ? 'Success' : 'Failed'}: ${result.message ?? '(no message)'}`;
10183
10606
  }
10184
10607
 
10608
+ // ── Shopify write policy (velaro-messaging /api/Shopify/WritePolicy) ───────
10609
+ case 'shopify_get_write_policy': {
10610
+ return JSON.stringify(await messagingGet('/Shopify/WritePolicy'), null, 2);
10611
+ }
10612
+ case 'shopify_set_write_policy': {
10613
+ const body = {};
10614
+ for (const k of ['enableShopifyWrite', 'requireConfirmation', 'maxRefundAmount', 'maxDiscountPercent', 'maxDiscountFixedAmount']) {
10615
+ if (args[k] !== undefined) body[k] = args[k];
10616
+ }
10617
+ if (!Object.keys(body).length) throw new Error('Provide at least one field to change.');
10618
+ const result = await messagingPut('/Shopify/WritePolicy', body);
10619
+ return `Shopify write policy updated.
10620
+ ${JSON.stringify(result, null, 2)}`;
10621
+ }
10185
10622
  // ── Webflow (config CRUD) ──────────────────────────────────────────────────
10186
10623
  case 'webflow_get_config': {
10187
10624
  if (args.configId) {
@@ -10742,6 +11179,19 @@ async function handleTool(name, args) {
10742
11179
  return lines.join(String.fromCharCode(10));
10743
11180
  }
10744
11181
 
11182
+ case 'entitlement_report_packages': {
11183
+ const q = args && args.siteId ? `?siteId=${encodeURIComponent(args.siteId)}` : '';
11184
+ const pkg = await api('GET', `/Entitlements/report-packages${q}`);
11185
+ const lines = [`${pkg.top.name} (${pkg.top.slug}) - plan ${pkg.plan}, default OFF${pkg.siteId ? `, site ${pkg.siteId}${pkg.top.enabled ? ' [ALL ON]' : ''}` : ''}`, ` ${pkg.top.tagline}`];
11186
+ for (const c of pkg.categories) {
11187
+ lines.push('');
11188
+ lines.push(`${c.name} (${c.slug})${pkg.siteId ? ` [${c.enabled ? 'ON ' : 'off'}] ${c.enabledCount}/${c.reportCount} reports` : ` ${c.reportCount} reports`}`);
11189
+ lines.push(` ${c.tagline}`);
11190
+ for (const r of c.reports) lines.push(` ${pkg.siteId ? (r.enabled ? '[ON ] ' : '[off] ') : ''}${r.name} (${r.id}) - package ${r.packageSlug}, key ${r.packageKey}`);
11191
+ }
11192
+ return lines.join(String.fromCharCode(10));
11193
+ }
11194
+
10745
11195
  case 'entitlement_list_features': {
10746
11196
  const features = await api('GET', '/Entitlements/features');
10747
11197
  const byCategory = {};
@@ -10946,7 +11396,12 @@ async function handleTool(name, args) {
10946
11396
  throw new Error('Date range too large. Worked Hours can be queried for at most 90 days at a time.');
10947
11397
  }
10948
11398
  }
10949
- return await messagingPost('/Reports/worked-hours-breakdown', { Start: args.start, End: args.end });
11399
+ // Server End is exclusive: a date-only end means "through the end of that day".
11400
+ let endValue = args.end;
11401
+ if (/^\d{4}-\d{2}-\d{2}$/.test(String(args.end).trim()) && !Number.isNaN(endMs)) {
11402
+ endValue = new Date(endMs + 24 * 60 * 60 * 1000).toISOString();
11403
+ }
11404
+ return await messagingPost('/Reports/worked-hours-breakdown', { Start: args.start, End: endValue });
10950
11405
  }
10951
11406
 
10952
11407
  // -- Pickup & Reminder Texting --
@@ -10994,6 +11449,25 @@ async function handleTool(name, args) {
10994
11449
  return JSON.stringify(result, null, 2);
10995
11450
  }
10996
11451
 
11452
+ case 'apple_pay_get_config': {
11453
+ const config = await messagingGet('/ApplePayConfig');
11454
+ return JSON.stringify(config, null, 2);
11455
+ }
11456
+
11457
+ case 'apple_pay_update_config': {
11458
+ // Field names must match ApplePayConfigUpdateRequest exactly (apply-if-present: undefined is dropped by JSON).
11459
+ const body = {
11460
+ merchantDisplayName: args.merchantDisplayName,
11461
+ isTestMode: args.isTestMode,
11462
+ bubbleLogoBase64: args.bubbleLogoBase64,
11463
+ };
11464
+ const existing = await messagingGet('/ApplePayConfig');
11465
+ const result = existing && existing.id > 0
11466
+ ? await messagingPut(`/ApplePayConfig/${existing.id}`, body)
11467
+ : await messagingPost('/ApplePayConfig', body);
11468
+ return JSON.stringify(result, null, 2);
11469
+ }
11470
+
10997
11471
  // -- Payment Recovery --
10998
11472
  case 'payment_recovery_send': {
10999
11473
  // Field names must match SendPaymentReminderRequest exactly.
@@ -11658,7 +12132,7 @@ const server = new Server(
11658
12132
  { capabilities: { tools: {}, prompts: {} } }
11659
12133
  );
11660
12134
 
11661
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
12135
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map(withAnnotations) }));
11662
12136
 
11663
12137
  server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
11664
12138