@velaro/mcp-server 1.0.0 → 1.0.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/server.js +359 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {
package/server.js CHANGED
@@ -232,6 +232,65 @@ function execInsightsRange(days) {
232
232
 
233
233
  const messagingDel = (path) => messagingRequest('DELETE', path);
234
234
 
235
+ // Visitor link helpers (mirrors cli/lib/visitor-link.js; keep in sync, covered by cli/lib/visitor-link.test.js)
236
+
237
+ const MAX_TTL_MINUTES = 1440;
238
+
239
+ function decodeLinkToken(token) {
240
+ const out = { ok: false, problems: [] };
241
+ if (typeof token !== 'string' || !token.trim()) { out.problems.push('empty token'); return out; }
242
+ const parts = token.trim().split('.');
243
+ if (parts.length !== 3 || parts[0] !== 'vlk1') { out.problems.push('not a vlk1 link token (expected vlk1.<payload>.<signature>)'); return out; }
244
+ const payload = Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
245
+ const f = payload.split('|');
246
+ if (f.length < 6) { out.problems.push('payload has fewer than 6 fields'); return out; }
247
+ const [siteId, visitorId, conversationBaseId, scope, iat, exp] = f;
248
+ const toMs = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? (n > 1e12 ? n : n * 1000) : null; };
249
+ const issuedMs = toMs(iat);
250
+ const expMs = toMs(exp);
251
+ Object.assign(out, {
252
+ ok: true,
253
+ siteId: Number(siteId) || null,
254
+ visitorId,
255
+ conversationBaseId: Number(conversationBaseId) || null,
256
+ scope,
257
+ issuedAt: issuedMs ? new Date(issuedMs).toISOString() : null,
258
+ expiresAt: expMs ? new Date(expMs).toISOString() : null,
259
+ signaturePresent: parts[2].length > 0,
260
+ });
261
+ if (scope !== 'link') out.problems.push(`unexpected scope "${scope}" (v1 only issues "link")`);
262
+ if (!out.signaturePresent) out.problems.push('signature missing');
263
+ if (!expMs) out.problems.push('expiry not parseable');
264
+ return out;
265
+ }
266
+
267
+ function diagnoseLink({ token, siteId, settings, now = Date.now() }) {
268
+ const claims = token ? decodeLinkToken(token) : { ok: false, problems: [] };
269
+ const findings = [...claims.problems];
270
+ if (claims.ok) {
271
+ if (siteId && claims.siteId && Number(siteId) !== claims.siteId) findings.push(`token was minted for site ${claims.siteId}, not site ${siteId}: cross-site tokens always fail`);
272
+ if (claims.expiresAt && Date.parse(claims.expiresAt) <= now) findings.push(`token expired at ${claims.expiresAt}`);
273
+ if (!claims.conversationBaseId) findings.push('token carries no conversation id (visitor had not started a chat when it was minted)');
274
+ }
275
+ if (settings) {
276
+ if (!settings.enabled) findings.push('visitor linking is OFF for this site: mint returns linking_disabled');
277
+ if (settings.keyConfigured === false) findings.push('link signing key is not configured on the server: mint and resolve fail closed (503 key_not_configured)');
278
+ }
279
+ return { claims, settings: settings ?? null, findings, healthy: findings.length === 0 };
280
+ }
281
+
282
+ function validateSettingsArgs({ enabled, ttlMinutes }) {
283
+ if (enabled === undefined && ttlMinutes === undefined) throw new Error('Pass enabled and/or ttlMinutes.');
284
+ if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes < 1 || ttlMinutes > MAX_TTL_MINUTES)) {
285
+ throw new Error(`ttlMinutes must be a whole number from 1 to ${MAX_TTL_MINUTES}.`);
286
+ }
287
+ const body = {};
288
+ if (enabled !== undefined) body.enabled = !!enabled;
289
+ if (ttlMinutes !== undefined) body.ttlMinutes = ttlMinutes;
290
+ return body;
291
+ }
292
+
293
+
235
294
  // Skill-based routing CRUD helper for SkillRouting/{Skills|Assignments|Requirements}.
236
295
  async function skillRoutingCrud(resource, args, fields) {
237
296
  const body = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined));
@@ -823,6 +882,16 @@ const TOOLS = [
823
882
  },
824
883
  },
825
884
 
885
+ {
886
+ name: 'skill_usage_site',
887
+ description: 'Velaro staff only. Per-customer skill and integration usage for the month in one call: the billable counters (AI tool calls, MCP calls, API calls, workflow integration actions, workflow AI calls, bot turns) against the plan limits (same numbers as the customer Billing page), the effective usage-gate rollout mode per metric (Off/Observe/Enforce), calls by surface (MCP gateway, bot, workflow, REST business action, staff, copilot), and LLM cost split platform vs BYOK. BYOK removes LLM cost only; skill usage is counted and capped the same way. Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
888
+ inputSchema: {
889
+ type: 'object',
890
+ properties: { siteId: { type: 'number', description: 'Target site ID (need not be your own)' } },
891
+ required: ['siteId'],
892
+ },
893
+ },
894
+
826
895
  {
827
896
  name: 'scraper_verify_token',
828
897
  description: "Show the calling site's web-scraper verification header (name + value), the scraper IP, and ready-to-send instructions for allowlisting Velaro's crawler with the customer's web host/WAF. Wraps GET ScraperJobs/VerifyToken. Site comes from auth context, never a parameter.",
@@ -1041,6 +1110,18 @@ const TOOLS = [
1041
1110
  },
1042
1111
  },
1043
1112
 
1113
+ {
1114
+ name: 'csat_override_unapprove_candidate',
1115
+ description: 'Undo an approved Chat Intelligence improvement candidate: returns it to Pending, deactivates the promoted knowledge override and removes any vectorized copy. Requires site manager. Site comes from the key, never an argument.',
1116
+ inputSchema: {
1117
+ type: 'object',
1118
+ properties: {
1119
+ candidateId: { type: 'number', description: 'Id of the Approved override candidate' },
1120
+ },
1121
+ required: ['candidateId'],
1122
+ },
1123
+ },
1124
+
1044
1125
  // ── Conversations ─────────────────────────────────────────────────────────
1045
1126
  {
1046
1127
  name: 'conversation_list',
@@ -1761,6 +1842,41 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
1761
1842
  required: ['message'],
1762
1843
  },
1763
1844
  },
1845
+ {
1846
+ name: 'visitor_link_get_settings',
1847
+ description: 'Get the visitor-linking settings for this site: whether chat visitors can be linked to later form/survey submissions (default off), the link lifetime in minutes, and whether the signing key is configured. Site is resolved server-side.',
1848
+ inputSchema: { type: 'object', properties: {}, required: [] },
1849
+ },
1850
+ {
1851
+ name: 'visitor_link_set_settings',
1852
+ description: 'Turn visitor linking on or off for this site and/or set the link lifetime (1-1440 minutes). Only fields you pass change. Links are only created for visitors who have chatted and, when the site uses a consent banner, granted functional storage.',
1853
+ inputSchema: {
1854
+ type: 'object',
1855
+ properties: {
1856
+ enabled: { type: 'boolean', description: 'Enable or disable visitor linking' },
1857
+ ttlMinutes: { type: 'number', description: 'Link lifetime in minutes, 1-1440' },
1858
+ },
1859
+ required: [],
1860
+ },
1861
+ },
1862
+ {
1863
+ name: 'visitor_link_diagnose',
1864
+ description: 'Explain why visitor linking is or is not working for this site. Reads the current settings and, if you pass a vlk1 link token, decodes its claims (site, conversation, expiry) and reports cross-site, expired, missing-conversation and key problems. The signature is not verified here; use visitor_link_resolve for the real server-side check.',
1865
+ inputSchema: {
1866
+ type: 'object',
1867
+ properties: { token: { type: 'string', description: 'Optional vlk1 link token from a form hidden field' } },
1868
+ required: [],
1869
+ },
1870
+ },
1871
+ {
1872
+ name: 'visitor_link_resolve',
1873
+ description: 'Exchange a vlk1 link token for the visitor id, conversation id and contact id (what your own server does when a form arrives). Cross-site and expired tokens fail.',
1874
+ inputSchema: {
1875
+ type: 'object',
1876
+ properties: { token: { type: 'string', description: 'vlk1 link token' } },
1877
+ required: ['token'],
1878
+ },
1879
+ },
1764
1880
  {
1765
1881
  name: 'teams_push_settings_get',
1766
1882
  description: 'Get the Velaro Bot proactive-alert settings for this site: per-alert switches (new chat, missed chat, ticket created, ticket assigned, daily digest), the effective daily cap and the push count for today. Site is resolved server-side.',
@@ -2523,6 +2639,42 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
2523
2639
  required: ['siteId', 'indexName'],
2524
2640
  },
2525
2641
  },
2642
+ {
2643
+ name: 'knowledge_clinical_text_get',
2644
+ description: 'Read the Emergency and Safety Messages for a site: per-channel and per-language emergency/referral overrides (keys like chat, es, es.voice), the effective English wording, supportedLanguages, limits, and the read-only emergencyServiceMode. Customer: own site. Velaro staff: pass siteId. Wraps GET KnowledgeSetup/state (clinicalText).',
2645
+ inputSchema: {
2646
+ type: 'object',
2647
+ properties: { siteId: { type: 'number', description: 'Velaro staff only: target site ID. Omit for your own site.' } },
2648
+ },
2649
+ },
2650
+ {
2651
+ name: 'knowledge_clinical_text_set',
2652
+ description: 'Set the Emergency and Safety Messages. The server replaces the stored override set, so pass every override to keep (read first with knowledge_clinical_text_get). Keys: default, chat, voice, sms, whatsapp, email, a language code (es fr de pt it nl zh ja ko vi ru ar hi pl) or language.channel (es.voice). Each text 20-600 chars; emergency text must contain {emergencyNumber}; "hang up" only in voice wording. null value clears an entry. Wraps PUT KnowledgeSetup/clinical-text.',
2653
+ inputSchema: {
2654
+ type: 'object',
2655
+ properties: {
2656
+ siteId: { type: 'number', description: 'Velaro staff only: target site ID. Omit for your own site.' },
2657
+ emergency: { type: 'object', description: 'Map of key to emergency wording', additionalProperties: { type: ['string', 'null'] } },
2658
+ referral: { type: 'object', description: 'Map of key to referral wording', additionalProperties: { type: ['string', 'null'] } },
2659
+ emergencyNumber: { type: 'string', description: 'Replaces {emergencyNumber}; omit for the default' },
2660
+ resetToDefault: { type: 'boolean', description: 'Clear all custom wording' },
2661
+ },
2662
+ },
2663
+ },
2664
+ {
2665
+ name: 'knowledge_emergency_service_mode_set',
2666
+ description: 'Velaro staff only. Turn emergency-service mode on/off for a site that IS the emergency line: its own required text replaces the built-in emergency instruction (never the referral). Turning on needs confirm=true and text of 20-600 chars. Wraps PUT KnowledgeSetup/Staff/{siteId}/clinical-text (emergencyServiceMode); existing wording is left untouched.',
2667
+ inputSchema: {
2668
+ type: 'object',
2669
+ properties: {
2670
+ siteId: { type: 'number', description: 'Target site ID' },
2671
+ enabled: { type: 'boolean' },
2672
+ text: { type: 'string', description: 'Required emergency text (20-600 chars)' },
2673
+ confirm: { type: 'boolean', description: 'Must be true to turn the mode on' },
2674
+ },
2675
+ required: ['siteId'],
2676
+ },
2677
+ },
2526
2678
  {
2527
2679
  name: 'index_set_profile',
2528
2680
  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).',
@@ -3194,6 +3346,50 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3194
3346
  required: ['id'],
3195
3347
  },
3196
3348
  },
3349
+ // ── Visitor Monitor (session-scoped: SiteId comes from the caller's JWT, never an arg) ──
3350
+ {
3351
+ name: 'visitor_monitor_active',
3352
+ description: 'List live visitors on this site (Visitor Monitor). Returns sessions (with region/city/ipTruncated when Store Location Detail is on), activeWindowMinutes, locationDetailEnabled, visitor limit and counts.',
3353
+ inputSchema: { type: 'object', properties: {}, required: [] },
3354
+ },
3355
+ {
3356
+ name: 'visitor_monitor_preferences_get',
3357
+ description: 'Get the calling user\'s saved Visitor Monitor view (columns, groupBy, sortBy, sortDir, regionGrouping). Per user, follows them across browsers.',
3358
+ inputSchema: { type: 'object', properties: {}, required: [] },
3359
+ },
3360
+ {
3361
+ name: 'visitor_monitor_preferences_set',
3362
+ description: 'Save the calling user\'s Visitor Monitor view. Replaces the saved view: send the full set of fields you want kept.',
3363
+ inputSchema: {
3364
+ type: 'object',
3365
+ properties: {
3366
+ columns: { type: 'array', items: { type: 'string' }, description: 'Ordered column keys, e.g. ["visitor","status","region","city"]' },
3367
+ groupBy: { type: 'string', description: 'Group the list by this key (e.g. "region", "city", "none")' },
3368
+ sortBy: { type: 'string', description: 'Sort key, e.g. "lastSeen"' },
3369
+ sortDir: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction' },
3370
+ regionGrouping: { type: 'string', description: 'Region grouping granularity: "region" or "city"' },
3371
+ },
3372
+ required: [],
3373
+ },
3374
+ },
3375
+ {
3376
+ name: 'visitor_monitor_settings_get',
3377
+ description: 'Get this site\'s Visitor Monitor settings: storeLocationDetail and activeWindowMinutes (how long a visitor stays listed after leaving; default 30).',
3378
+ inputSchema: { type: 'object', properties: {}, required: [] },
3379
+ },
3380
+ {
3381
+ name: 'visitor_monitor_settings_set',
3382
+ description: 'Update this site\'s Visitor Monitor settings (partial update). activeWindowMinutes must be 5-240; above 30 and storeLocationDetail need the Visitor Insights Advanced plan flag, otherwise the API returns 403 feature_not_in_plan.',
3383
+ inputSchema: {
3384
+ type: 'object',
3385
+ properties: {
3386
+ storeLocationDetail: { type: 'boolean', description: 'Store visitor region/city and a truncated IP on new sessions (Advanced plan)' },
3387
+ activeWindowMinutes: { type: 'number', description: 'Minutes a visitor stays in the monitor after last activity (5-240; above 30 needs Advanced)' },
3388
+ },
3389
+ required: [],
3390
+ },
3391
+ },
3392
+
3197
3393
  // ── Page Widget Containers ────────────────────────────────────────────────
3198
3394
  {
3199
3395
  name: 'widget_container_list',
@@ -3544,7 +3740,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3544
3740
  inputSchema: {
3545
3741
  type: 'object',
3546
3742
  properties: {
3547
- 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'] },
3743
+ 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', 'bonus_insights', 'operations_briefing', 'entitled'] },
3548
3744
  from: { type: 'string', description: 'yyyy-MM-dd start (default: 30 days ago).' },
3549
3745
  to: { type: 'string', description: 'yyyy-MM-dd end, inclusive (default: today).' },
3550
3746
  groupBy: { type: 'string', description: 'program_breakdown: website|team|platform|device|entrypage. sentiment_breakdown: website|team|agent|theme.' },
@@ -3561,6 +3757,13 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
3561
3757
  timeZone: { type: 'string', description: 'demand_coverage: IANA time zone (default America/Los_Angeles).' },
3562
3758
  lowScore: { type: 'number', description: 'bot_containment: QA score below which a handed-off chat counts as regret (1-100, default 60).' },
3563
3759
  includeDemo: { type: 'boolean', description: 'true = show only the seeded sample (demo) data. Default false.' },
3760
+ name: { type: 'string', description: 'bonus_insights: which analysis: coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes.' },
3761
+ narrative: { type: 'boolean', description: 'operations_briefing: include the cached AI narrative (default true).' },
3762
+ minutesPerReview: { type: 'number', description: 'bonus_insights: assumed minutes per manual review (0-240).' },
3763
+ hourlyCost: { type: 'number', description: 'bonus_insights: assumed reviewer hourly cost (0-1000).' },
3764
+ deflectionPct: { type: 'number', description: 'bonus_insights: assumed deflection percent (0-100).' },
3765
+ costPerChat: { type: 'number', description: 'bonus_insights: assumed cost per chat (0-1000).' },
3766
+ waitMinutes: { type: 'number', description: 'bonus_insights: wait minutes that counts as abandoned (1-1440).' },
3564
3767
  promiseId: { type: 'string', description: 'promise_dismiss: the promise id to dismiss (managers only).' },
3565
3768
  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.' },
3566
3769
  },
@@ -4706,6 +4909,15 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4706
4909
  },
4707
4910
  },
4708
4911
 
4912
+ {
4913
+ name: 'netsuite_rollback_auth',
4914
+ description: 'Undo a NetSuite connection-type switch: sets a connection that was moved to OAuth 2.0 / M2M back to the legacy OAuth 1.0a credentials. Refused (409) once the old credentials were cleared. Nothing is deleted. Administrator only, audited. Lives in velaro-messaging - requires VELARO_MESSAGING_API.',
4915
+ inputSchema: {
4916
+ type: 'object',
4917
+ properties: { configId: { type: 'number', description: 'NetSuite config ID (see netsuite_list_configs).' } },
4918
+ required: ['configId'],
4919
+ },
4920
+ },
4709
4921
  {
4710
4922
  name: 'netsuite_suiteapp_download',
4711
4923
  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.',
@@ -4782,7 +4994,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
4782
4994
  },
4783
4995
  {
4784
4996
  name: 'teams_phone_test_config',
4785
- description: 'Test the Teams Phone connection by creating (or renewing) the Graph /communications/calls subscription — the real connectivity check. Surfaces the actual Graph error if the customer tenant hasn\'t admin-consented Calls.AccessMedia.All + Calls.Initiate.All, or the resource account isn\'t provisioned, rather than only being discovered when a call is missed. Lives in velaro-messaging — requires VELARO_MESSAGING_API.',
4997
+ description: 'Run the Teams Phone readiness checks and return each check (name, ok, message) plus callingReady. Surfaces what is missing (admin consent, resource account, Graph reachability) rather than only being discovered when a call is missed. Lives in velaro-messaging — requires VELARO_MESSAGING_API.',
4786
4998
  inputSchema: { type: 'object', properties: {}, required: [] },
4787
4999
  },
4788
5000
 
@@ -5013,6 +5225,21 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5013
5225
  // Distinct from the reusable coupon-CODE catalog, which lives in velaro-messaging
5014
5226
  // (superadmin/billing/coupons). Pass couponCode here only to record which catalog code a grant
5015
5227
  // corresponds to.
5228
+ {
5229
+ name: 'sign_versions_list',
5230
+ description: 'Velaro Sign: list every uploaded document version of a contract (owner-only; signers never see old versions). Newest first with sha256, pages, uploader, note.',
5231
+ inputSchema: { type: 'object', properties: { contractId: { type: 'number', description: 'Contract ID' } }, required: ['contractId'] },
5232
+ },
5233
+ {
5234
+ name: 'sign_versions_diff',
5235
+ description: 'Velaro Sign: text diff between two document versions of a contract (changed blocks only: removed vs added lines). Owner-only.',
5236
+ inputSchema: { type: 'object', properties: { contractId: { type: 'number' }, from: { type: 'number', description: 'Older version number' }, to: { type: 'number', description: 'Newer version number' } }, required: ['contractId', 'from', 'to'] },
5237
+ },
5238
+ {
5239
+ name: 'sign_versions_upload',
5240
+ description: 'Velaro Sign: upload a new PDF version of a contract document from a local file path. Allowed only while the contract is a draft; after send/sign, void and re-send.',
5241
+ inputSchema: { type: 'object', properties: { contractId: { type: 'number' }, filePath: { type: 'string', description: 'Local path of the PDF' }, note: { type: 'string', description: 'Change note' } }, required: ['contractId', 'filePath'] },
5242
+ },
5016
5243
  {
5017
5244
  name: 'coupon_grant_list',
5018
5245
  description: 'List per-account Stripe discount grants. Velaro billing staff only. Shows which accounts currently have a discount and its status.',
@@ -5138,6 +5365,10 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
5138
5365
  required: ['id'],
5139
5366
  },
5140
5367
  },
5368
+ {
5369
+ name: 'usage_alerts',
5370
+ description: 'Plan-limit alerts for the authenticated site: every metered resource at or above its warning threshold (80%+), with used/limit, percent, level (warning | critical | limit) and whether overage is billed. Same rules as the usage-limit emails and the dashboard banner. Use to answer "are they near a limit?" or "why did they get a usage email?". Read-only. To raise a limit, a customer uses Billing > Contact Account Manager; staff use support_set_limit.',
5371
+ },
5141
5372
  {
5142
5373
  name: 'billing_notify_settings_get',
5143
5374
  description: 'Billing notifications: the global settings (receipts.card, receipts.ach, receipts.other, invoices.bcc, digest.recipients). Staff only.',
@@ -6400,7 +6631,7 @@ For multiSelect dynamic options: the variable named in dynamicOptionsVariable mu
6400
6631
  {"name":"skill_routing_assignments","description":"Skill-based routing: assign skills to agents with proficiency 1-5. action = list | create | update | delete.","inputSchema":{"type":"object","properties":{"action":{"type":"string"},"id":{"type":"number"},"workspaceUserId":{"type":"number"},"agentSkillId":{"type":"number"},"proficiency":{"type":"number"}},"required":["action"]}},
6401
6632
  {"name":"skill_routing_requirements","description":"Skill-based routing: required/preferred skills per deployment and/or team. action = list | create | update | delete. Update is partial. Requirements are disabled by default; set isEnabled true to turn on.","inputSchema":{"type":"object","properties":{"action":{"type":"string"},"id":{"type":"number"},"deploymentId":{"type":"number"},"teamId":{"type":"number"},"requiredSkillIds":{"type":"array","items":{"type":"number"}},"preferredSkillIds":{"type":"array","items":{"type":"number"}},"overflowWaitSeconds":{"type":"number"},"isEnabled":{"type":"boolean"}},"required":["action"]}},
6402
6633
  {"name":"skill_routing_simulate","description":"Dry-run skill routing for a team: shows which agents would be narrowed to and why. Never assigns a conversation or writes the decision log.","inputSchema":{"type":"object","properties":{"teamId":{"type":"number"},"waitedSeconds":{"type":"number"}}}},
6403
- {"name":"qa_rubric","description":"Conversation Quality QA rubrics. action = list | get | default | create | update | delete. Pass rubric fields in \"rubric\". Requires EnableConversationQuality.","inputSchema":{"type":"object","properties":{"action":{"type":"string"},"id":{"type":"number"},"rubric":{"type":"object"}},"required":["action"]}},
6634
+ {"name":"qa_rubric","description":"Conversation Quality QA rubrics. action = list | get | default | starter (create the starter rubric if the site has none) | create | update | clone (inactive copy; optional rubric.name) | activate | deactivate | rescore (re-score recent chats against the active rubric; pass days 1-90 and limit 1-500) | delete. Pass rubric fields in \"rubric\". Weights are relative and shown as percents; max 20 criteria. Requires EnableConversationQuality and the Administrator role for writes.","inputSchema":{"type":"object","properties":{"action":{"type":"string"},"id":{"type":"number"},"rubric":{"type":"object"},"days":{"type":"number"},"limit":{"type":"number"}},"required":["action"]}},
6404
6635
  {"name":"qa_low_scores","description":"Conversations scoring below the rubric threshold, for QA review.","inputSchema":{"type":"object","properties":{"threshold":{"type":"number"},"days":{"type":"number"},"agentId":{"type":"number"},"skip":{"type":"number"},"take":{"type":"number"}}}},
6405
6636
  {"name":"ai_field_extraction_get_config","description":"Get the AI field extraction config (opt-in, default off; monthly cap). Requires the ai_field_extraction entitlement.","inputSchema":{"type":"object","properties":{}}},
6406
6637
  {"name":"ai_field_extraction_update_config","description":"Update AI field extraction config (partial). enabled, monthlyCap (null = 500 default, hard ceiling 50000), createThemeTags.","inputSchema":{"type":"object","properties":{"enabled":{"type":"boolean"},"monthlyCap":{"type":"number"},"createThemeTags":{"type":"boolean"}}}},
@@ -6921,6 +7152,11 @@ async function handleTool(name, args) {
6921
7152
  return JSON.stringify(result, null, 2);
6922
7153
  }
6923
7154
 
7155
+ case 'skill_usage_site': {
7156
+ const usage = await messagingGet(`/SupportTools/sites/${args.siteId}/skill-usage`);
7157
+ return JSON.stringify(usage, null, 2);
7158
+ }
7159
+
6924
7160
  case 'apple_health': {
6925
7161
  const h = await messagingGet(`/SupportTools/sites/${args.siteId}/apple/health`);
6926
7162
  return JSON.stringify(h, null, 2);
@@ -7030,6 +7266,12 @@ async function handleTool(name, args) {
7030
7266
  { dismissed: args.dismissed !== false }
7031
7267
  );
7032
7268
 
7269
+ case 'csat_override_unapprove_candidate': {
7270
+ const cid = Number(args.candidateId);
7271
+ if (!Number.isInteger(cid) || cid <= 0) throw new Error('candidateId must be a positive integer');
7272
+ return await messagingPost(`/api/intelligence/csat-override-candidates/${cid}/unapprove`, {});
7273
+ }
7274
+
7033
7275
  // ── Conversations ──────────────────────────────────────────────────────
7034
7276
  case 'conversation_list':
7035
7277
  return await rpc('conversation_list', args);
@@ -7584,6 +7826,20 @@ reply: ${r?.reply}`;
7584
7826
  return `Sent to Teams webhook "${webhook.name}".`;
7585
7827
  }
7586
7828
 
7829
+ case 'visitor_link_get_settings':
7830
+ return await messagingGet('/SiteSettings/visitor-linking');
7831
+
7832
+ case 'visitor_link_set_settings':
7833
+ return await messagingPut('/SiteSettings/visitor-linking', validateSettingsArgs({ enabled: args.enabled, ttlMinutes: args.ttlMinutes }));
7834
+
7835
+ case 'visitor_link_diagnose': {
7836
+ const settings = await messagingGet('/SiteSettings/visitor-linking');
7837
+ return diagnoseLink({ token: args.token, settings });
7838
+ }
7839
+
7840
+ case 'visitor_link_resolve':
7841
+ return await messagingPost('/api/v1/visitor-links/resolve', { token: args.token });
7842
+
7587
7843
  case 'teams_push_settings_get':
7588
7844
  return await api('GET', '/TeamsBot/PushSettings');
7589
7845
 
@@ -8584,6 +8840,34 @@ reply: ${r?.reply}`;
8584
8840
  return `Index ready on site ${args.siteId}: ${idx?.name} (id=${idx?.id}, purpose=${idx?.purpose}, excludeFromAllSources=${!!idx?.excludeFromAllSources}).`;
8585
8841
  }
8586
8842
 
8843
+ case 'knowledge_clinical_text_get': {
8844
+ const base = args.siteId ? `/KnowledgeSetup/Staff/${encodeURIComponent(args.siteId)}` : '/KnowledgeSetup';
8845
+ const st = await messagingRequest('GET', `${base}/state`);
8846
+ return JSON.stringify(st?.clinicalText ?? { error: 'state did not include clinicalText' }, null, 2);
8847
+ }
8848
+
8849
+ case 'knowledge_clinical_text_set': {
8850
+ const base = args.siteId ? `/KnowledgeSetup/Staff/${encodeURIComponent(args.siteId)}` : '/KnowledgeSetup';
8851
+ const body = args.resetToDefault ? { resetToDefault: true } : {};
8852
+ if (!args.resetToDefault) {
8853
+ if (args.emergency !== undefined) body.emergency = args.emergency;
8854
+ if (args.referral !== undefined) body.referral = args.referral;
8855
+ if (args.emergencyNumber !== undefined) body.emergencyNumber = args.emergencyNumber;
8856
+ }
8857
+ const r = await messagingRequest('PUT', `${base}/clinical-text`, body);
8858
+ return JSON.stringify(r, null, 2);
8859
+ }
8860
+
8861
+ case 'knowledge_emergency_service_mode_set': {
8862
+ if (!args.siteId) return 'siteId is required.';
8863
+ const mode = {};
8864
+ if (args.enabled !== undefined) mode.enabled = args.enabled;
8865
+ if (args.text !== undefined) mode.text = args.text;
8866
+ if (args.confirm) mode.confirm = true;
8867
+ const r = await messagingRequest('PUT', `/KnowledgeSetup/Staff/${encodeURIComponent(args.siteId)}/clinical-text`, { emergencyServiceMode: mode });
8868
+ return JSON.stringify(r, null, 2);
8869
+ }
8870
+
8587
8871
  case 'index_set_profile': {
8588
8872
  if (!args.siteId || !args.indexName) return 'siteId and indexName are required.';
8589
8873
  const body = { indexName: args.indexName };
@@ -9476,6 +9760,25 @@ reply: ${r?.reply}`;
9476
9760
  return `Widget ${args.id} ${args.isEnabled ? 'enabled' : 'disabled'}.`;
9477
9761
  }
9478
9762
 
9763
+ // ── Visitor Monitor ───────────────────────────────────────────────────
9764
+ case 'visitor_monitor_active':
9765
+ return JSON.stringify(await messagingGet('/Visitor/active'), null, 2);
9766
+ case 'visitor_monitor_preferences_get':
9767
+ return JSON.stringify(await messagingGet('/Visitor/preferences'), null, 2);
9768
+ case 'visitor_monitor_preferences_set': {
9769
+ const body = {};
9770
+ for (const k of ['columns', 'groupBy', 'sortBy', 'sortDir', 'regionGrouping']) if (args[k] !== undefined) body[k] = args[k];
9771
+ return JSON.stringify(await messagingPut('/Visitor/preferences', body), null, 2);
9772
+ }
9773
+ case 'visitor_monitor_settings_get':
9774
+ return JSON.stringify(await messagingGet('/Visitor/settings'), null, 2);
9775
+ case 'visitor_monitor_settings_set': {
9776
+ const body = {};
9777
+ if (args.storeLocationDetail !== undefined) body.storeLocationDetail = args.storeLocationDetail;
9778
+ if (args.activeWindowMinutes !== undefined) body.activeWindowMinutes = args.activeWindowMinutes;
9779
+ return JSON.stringify(await messagingPut('/Visitor/settings', body), null, 2);
9780
+ }
9781
+
9479
9782
  // ── Page Widget Containers ────────────────────────────────────────────────
9480
9783
  case 'widget_container_list': {
9481
9784
  const data = await api('GET', '/PageWidgetContainers');
@@ -9794,6 +10097,7 @@ reply: ${r?.reply}`;
9794
10097
  weekly_brief: 'weekly-brief', weekly_brief_send_test: 'weekly-brief/send-test',
9795
10098
  repeat_contact: 'repeat-contact', demand_coverage: 'demand-coverage', bot_containment: 'bot-containment',
9796
10099
  effort_index: 'effort-index', promise_ledger: 'promise-ledger', new_agent_ramp: 'new-agent-ramp',
10100
+ bonus_insights: 'bonus', operations_briefing: 'operations-briefing', entitled: 'entitled',
9797
10101
  };
9798
10102
  if (args.report === 'promise_dismiss') {
9799
10103
  if (!args.promiseId) throw new Error('promiseId is required for promise_dismiss');
@@ -9826,11 +10130,15 @@ reply: ${r?.reply}`;
9826
10130
  if (f > t) throw new Error('from must be on or before to');
9827
10131
  return JSON.stringify(await messagingPost('/api/enterprise-reports/year-in-review', { from: midnightIso(f), to: midnightIso(addDays(t, 1)) }), null, 2);
9828
10132
  }
9829
- const seg = ER_PATHS[args.report];
10133
+ let seg = ER_PATHS[args.report];
9830
10134
  if (!seg) throw new Error('report must be one of: ' + Object.keys(ER_PATHS).join(', ') + ', promise_dismiss, year_in_review');
10135
+ if (args.report === 'bonus_insights') {
10136
+ if (!args.name) throw new Error('name is required for bonus_insights (coverage_gap|next_best_fix|escalation_risk|skill_gap_matrix|handle_quality_outliers|abandon_causes)');
10137
+ seg = 'bonus/' + encodeURIComponent(String(args.name).replace(/-/g, '_'));
10138
+ }
9831
10139
  if (args.report === 'weekly_brief_send_test') return JSON.stringify(await messagingPost('/api/enterprise-reports/' + seg, {}), null, 2);
9832
10140
  const qs = new URLSearchParams();
9833
- for (const k of ['from', 'to', 'groupBy', 'deploymentId', 'teamId', 'threshold', 'perAgent', 'limit', 'days', 'windowDays', 'weeks', 'handleMinutes', 'maxConcurrent', 'timeZone', 'lowScore', 'includeDemo']) {
10141
+ for (const k of ['from', 'to', 'groupBy', 'deploymentId', 'teamId', 'threshold', 'perAgent', 'limit', 'days', 'windowDays', 'weeks', 'handleMinutes', 'maxConcurrent', 'timeZone', 'lowScore', 'includeDemo', 'narrative', 'minutesPerReview', 'hourlyCost', 'deflectionPct', 'costPerChat', 'waitMinutes']) {
9834
10142
  if (args[k] != null && args[k] !== '') qs.set(k, String(args[k]));
9835
10143
  }
9836
10144
  const q = qs.toString();
@@ -10791,6 +11099,10 @@ reply: ${r?.reply}`;
10791
11099
  ? `Embed key exists but the SuiteApp is not connected (last polled ${d.lastPolledAt}, outside the connected window).`
10792
11100
  : 'Embed key exists but the SuiteApp has not polled yet.';
10793
11101
  }
11102
+ case 'netsuite_rollback_auth': {
11103
+ const r = await messagingPost('/NetSuite/Auth/RollbackAuthType', { id: args.configId });
11104
+ return `Config ${r?.id ?? args.configId} switched back to ${r?.authType ?? 'oauth1'}. Run a connection test to confirm.`;
11105
+ }
10794
11106
 
10795
11107
  case 'netsuite_m2m_init': {
10796
11108
  const r = await messagingPost('/NetSuite/M2M/Init', { id: args.configId ?? null, company: args.company, displayName: args.displayName });
@@ -10837,7 +11149,12 @@ reply: ${r?.reply}`;
10837
11149
  }
10838
11150
  case 'teams_phone_test_config': {
10839
11151
  const result = await messagingPost('/TeamsPhone/Config/Test', {});
10840
- return `${result.success ? 'Success' : 'Failed'}: ${result.message ?? '(no message)'}`;
11152
+ const lines = [`${result.success ? 'Success' : 'Failed'}: ${result.message ?? '(no message)'}`];
11153
+ if (typeof result.callingReady === 'boolean') lines.push(`Inbound calling ready: ${result.callingReady}`);
11154
+ if (Array.isArray(result.checks)) {
11155
+ for (const ck of result.checks) lines.push(`${ck?.ok ? 'PASS' : 'FAIL'} ${ck?.name ?? ''}${ck?.message ? ': ' + ck.message : ''}`);
11156
+ }
11157
+ return lines.join(String.fromCharCode(10));
10841
11158
  }
10842
11159
 
10843
11160
  // ── Shopify write policy (velaro-messaging /api/Shopify/WritePolicy) ───────
@@ -11182,6 +11499,31 @@ ${JSON.stringify(result, null, 2)}`;
11182
11499
  }
11183
11500
 
11184
11501
  // ── Stripe discount grants ────────────────────────────────────────────────────
11502
+ case 'sign_versions_list': {
11503
+ const r = await api('GET', `/api/Contract/${args.contractId}/versions`);
11504
+ const rows = r.versions || [];
11505
+ if (!rows.length) return `Contract ${args.contractId}: no document versions yet.`;
11506
+ return rows.map(v => `v${v.versionNumber} ${String(v.uploadedAt||'').slice(0,19)} ${v.uploadedBy||'-'} ${v.fileName||'-'} ${v.pageCount??'?'}p sha256:${String(v.sha256||'').slice(0,12)}${v.note ? ' note: '+v.note : ''}`).join('\n');
11507
+ }
11508
+
11509
+ case 'sign_versions_diff': {
11510
+ const r = await api('GET', `/api/Contract/${args.contractId}/diff?from=${args.from}&to=${args.to}`);
11511
+ if (r.identical) return `v${r.from} and v${r.to} are byte-identical.`;
11512
+ return (r.blocks || []).map(b => [...(b.removed||[]).map(x => '- '+x), ...(b.added||[]).map(x => '+ '+x)].join('\n')).join('\n\n') || 'No text differences.';
11513
+ }
11514
+
11515
+ case 'sign_versions_upload': {
11516
+ const buf = _readFileSync(args.filePath);
11517
+ if (buf.subarray(0, 5).toString('latin1') !== '%PDF-') throw new Error('Only PDF files can be uploaded as a contract version.');
11518
+ const form = new FormData();
11519
+ form.append('file', new Blob([buf], { type: 'application/pdf' }), String(args.filePath).split(/[\/]/).pop());
11520
+ if (args.note) form.append('note', args.note);
11521
+ const res = await fetch(`${API_BASE}/api/Contract/${args.contractId}/document`, { method: 'POST', headers: { Authorization: authHeader() }, body: form, signal: AbortSignal.timeout(60000) });
11522
+ const t = await res.text();
11523
+ if (!res.ok) throw new Error(`Upload failed ${res.status}: ${t.slice(0,300)} (new versions only while draft; after send/sign void and re-send)`);
11524
+ return `Uploaded new version for contract ${args.contractId}.`;
11525
+ }
11526
+
11185
11527
  case 'coupon_grant_list': {
11186
11528
  const qs = new URLSearchParams();
11187
11529
  if (args.siteId !== undefined) qs.set('siteId', String(args.siteId));
@@ -11284,6 +11626,11 @@ ${JSON.stringify(result, null, 2)}`;
11284
11626
  return `✅ Billing contact ${args.id} removed.`;
11285
11627
  }
11286
11628
 
11629
+ case 'usage_alerts': {
11630
+ const r = await messagingApi('/Usage/alerts');
11631
+ if (!r.alerts?.length) return 'No plan-limit alerts. Every metered resource is under its warning threshold.';
11632
+ return r.alerts.map(a => `[${a.level.toUpperCase()}] ${a.displayName}: ${a.current} of ${a.limit} (${a.percent}%)${a.softLimit ? ' - overage is billed' : ''}`).join(' | ');
11633
+ }
11287
11634
  case 'billing_notify_settings_get': {
11288
11635
  const all = await api('GET', '/api/BillingNotifications/settings');
11289
11636
  return Object.entries(all).map(([k, v]) => `${k}: ${v ?? '(not set: code default)'}`).join('\n');
@@ -12350,7 +12697,12 @@ ${JSON.stringify(result, null, 2)}`;
12350
12697
  if (a === 'create') return await messagingPost('/api/admin/QualityQa/rubrics', args.rubric ?? {});
12351
12698
  if (a === 'update') return await messagingPut('/api/admin/QualityQa/rubrics/' + args.id, args.rubric ?? {});
12352
12699
  if (a === 'delete') return await messagingDel('/api/admin/QualityQa/rubrics/' + args.id);
12353
- throw new Error('action must be list|get|default|create|update|delete');
12700
+ if (a === 'starter') return await messagingPost('/api/admin/QualityQa/rubrics/starter', {});
12701
+ if (a === 'clone') return await messagingPost('/api/admin/QualityQa/rubrics/' + args.id + '/clone', args.rubric ?? {});
12702
+ if (a === 'activate') return await messagingPut('/api/admin/QualityQa/rubrics/' + args.id, { isActive: true });
12703
+ if (a === 'deactivate') return await messagingPut('/api/admin/QualityQa/rubrics/' + args.id, { isActive: false });
12704
+ if (a === 'rescore') return await messagingPost('/api/admin/QualityQa/rescore?days=' + encodeURIComponent(args.days ?? 30) + '&limit=' + encodeURIComponent(args.limit ?? 200), {});
12705
+ throw new Error('action must be list|get|default|starter|create|update|clone|activate|deactivate|rescore|delete');
12354
12706
  }
12355
12707
  case 'qa_low_scores': {
12356
12708
  const qs = new URLSearchParams({ days: String(args.days ?? 30), skip: String(args.skip ?? 0), take: String(args.take ?? 50) });