@oxygen-agent/cli 1.1011.98 → 1.1011.322

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 (74) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.js +25 -5
  3. package/dist/column-run-notices.d.ts +16 -0
  4. package/dist/column-run-notices.js +57 -0
  5. package/dist/command-manifest.js +10 -1
  6. package/dist/connectors-commands.d.ts +11 -0
  7. package/dist/connectors-commands.js +63 -0
  8. package/dist/help.d.ts +27 -1
  9. package/dist/help.js +112 -29
  10. package/dist/http-client.js +8 -0
  11. package/dist/index.js +362 -130
  12. package/node_modules/@oxygen/cli-ugc/dist/commands.js +20 -69
  13. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +6 -4
  14. package/node_modules/@oxygen/shared/dist/byok-connect.js +11 -4
  15. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +131 -1
  16. package/node_modules/@oxygen/shared/dist/capability-discovery.js +325 -30
  17. package/node_modules/@oxygen/shared/dist/collab.d.ts +1 -1
  18. package/node_modules/@oxygen/shared/dist/collab.js +12 -0
  19. package/node_modules/@oxygen/shared/dist/column-decision.js +7 -3
  20. package/node_modules/@oxygen/shared/dist/column-output-fields.js +45 -3
  21. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +20 -13
  22. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +29 -13
  23. package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +23 -0
  24. package/node_modules/@oxygen/shared/dist/contracts/connectors.js +34 -0
  25. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +0 -6
  26. package/node_modules/@oxygen/shared/dist/contracts/operations.js +8 -1
  27. package/node_modules/@oxygen/shared/dist/contracts/sender-limits.js +1 -1
  28. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
  29. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +12 -1
  31. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +37 -0
  32. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +79 -44
  33. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +5 -0
  34. package/node_modules/@oxygen/shared/dist/feature-gates.js +6 -0
  35. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +57 -8
  36. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +74 -13
  37. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +7 -0
  38. package/node_modules/@oxygen/shared/dist/linkedin-url.js +9 -0
  39. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  40. package/node_modules/@oxygen/shared/dist/log.js +15 -0
  41. package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +597 -0
  42. package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +713 -0
  43. package/node_modules/@oxygen/shared/dist/mcp-events-enabled.d.ts +2 -0
  44. package/node_modules/@oxygen/shared/dist/mcp-events-enabled.js +14 -0
  45. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +1 -0
  46. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +6 -2
  47. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +7 -3
  48. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +22 -1
  49. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +3 -7
  50. package/node_modules/@oxygen/shared/dist/plan-limits.js +2 -14
  51. package/node_modules/@oxygen/shared/dist/select-options.d.ts +6 -0
  52. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  53. package/node_modules/@oxygen/shared/dist/sending-schedule.d.ts +39 -0
  54. package/node_modules/@oxygen/shared/dist/sending-schedule.js +41 -0
  55. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.d.ts +19 -0
  56. package/node_modules/@oxygen/shared/dist/sequence-lifecycle.js +20 -0
  57. package/node_modules/@oxygen/shared/dist/sequence-sender-variables.d.ts +35 -0
  58. package/node_modules/@oxygen/shared/dist/sequence-sender-variables.js +63 -0
  59. package/node_modules/@oxygen/shared/dist/sequences.d.ts +13 -7
  60. package/node_modules/@oxygen/shared/dist/sequences.js +26 -0
  61. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +2 -0
  62. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +5 -0
  63. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +1 -15
  64. package/node_modules/@oxygen/shared/dist/standard-webhooks.d.ts +51 -0
  65. package/node_modules/@oxygen/shared/dist/standard-webhooks.js +124 -0
  66. package/node_modules/@oxygen/shared/dist/tags.d.ts +1 -1
  67. package/node_modules/@oxygen/shared/dist/tags.js +13 -1
  68. package/node_modules/@oxygen/shared/dist/ugc.d.ts +11 -7
  69. package/node_modules/@oxygen/shared/dist/ugc.js +14 -6
  70. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  71. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  72. package/node_modules/@oxygen/shared/dist/zoned-time.d.ts +39 -0
  73. package/node_modules/@oxygen/shared/dist/zoned-time.js +81 -0
  74. package/package.json +1 -1
@@ -22,15 +22,16 @@ export const OXYGEN_CAPABILITY_ROUTES = [
22
22
  id: "workspace-access",
23
23
  layer: "Control",
24
24
  primitive: null,
25
- owns: "Authentication, CLI identities, API keys, sessions, organizations, and active-workspace selection.",
25
+ owns: "Authentication, CLI identities, API keys, sessions, organizations, active-workspace selection, and connected apps (ChatGPT, Claude, Codex…) with the default workspace each one works in.",
26
26
  notFor: "Workspace GTM data, provider credentials, or execution inside a primitive.",
27
- execution: "Resolve identity and select the hosted workspace before operating its primitives.",
27
+ execution: "Resolve identity and select the hosted workspace before operating its primitives. Each app connected with OAuth keeps its own default workspace: `connectors list` shows them and `connectors set` changes one app only; orgs select changes only the app or CLI that calls it.",
28
28
  posture: "mixed",
29
- gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select"],
30
- gatewayCommands: ["whoami", "orgs list", "orgs select"],
29
+ gatewayTools: ["oxygen_whoami", "oxygen_orgs_list", "oxygen_orgs_select", "oxygen_connector_list", "oxygen_connector_update"],
30
+ gatewayCommands: ["whoami", "orgs list", "orgs select", "connectors list", "connectors set"],
31
31
  skills: ["oxygen-quickstart"],
32
- endpointSections: ["api-keys", "auth", "login-requests", "orgs", "sessions", "settings", "whoami"],
33
- intentTerms: ["login", "authenticate", "identity", "who am i", "workspace", "organization", "org", "api key", "session", "switch workspace"],
32
+ // `mcp`: the host-driven MCP Events routes an OAuth connection's subscriptions go through (ADR 0030 §2).
33
+ endpointSections: ["api-keys", "auth", "connectors", "login-requests", "mcp", "orgs", "sessions", "settings", "whoami"],
34
+ intentTerms: ["login", "authenticate", "identity", "who am i", "workspace", "organization", "org", "api key", "session", "switch workspace", "connected app", "connected apps", "default workspace"],
34
35
  },
35
36
  {
36
37
  id: "discovery-and-skills",
@@ -214,7 +215,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
214
215
  primitive: "tables",
215
216
  owns: "Typed working datasets, rows, formulas, AI/tool/waterfall columns, reusable Functions with isolated drafts and published versions, cell state, projects, and run provenance.",
216
217
  notFor: "Canonical CRM truth, message cadence, or an off-platform spreadsheet runtime.",
217
- execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. To see what a table's own columns can be enriched with and what each costs per row before adding anything, run `tools search --for-table <table>` (MCP oxygen_tools_search with for_table): free, it returns priced suggestions with the exact add command. For standard person or company enrichment, `columns add <table> --preset person_enrich|company_enrich` (MCP oxygen_columns_add with preset) adds the maintained bundle in one call before any hand-built tool column. Person questions over that bundle's profile payload (skill set, grad school, location, job fit, current company) and person appearance research (events, keynotes, podcasts, GitHub profile) are `columns catalog --category people` templates, added with `columns add <table> --prompt-key <key> --input <name>=<column>`. For a company fact OXYGEN already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, LinkedIn/Crunchbase page lookups) or a page extraction, `columns catalog` lists the ready-made template and `columns add <table> --prompt-key <key> --input <name>=<column>` (MCP oxygen_columns_add with prompt_key) adds it with no prompt to write.",
218
+ execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. To see what a table's own columns can be enriched with and what each costs per row before adding anything, run `tools search --for-table <table>` (MCP oxygen_tools_search with for_table): free, it returns priced suggestions with the exact add command. For standard person or company enrichment, `columns add <table> --preset person_enrich|company_enrich` (MCP oxygen_columns_add with preset) adds the maintained bundle in one call before any hand-built tool column. Person questions over that bundle's profile payload (skill set, grad school, location, job fit, current company) and person appearance research (events, keynotes, podcasts, GitHub profile) are `columns catalog --category people` templates, added with `columns add <table> --prompt-key <key> --input <name>=<column>`. For a company fact OXYGEN already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, LinkedIn/Crunchbase page lookups) or a page extraction, `columns catalog` lists the ready-made template and `columns add <table> --prompt-key <key> --input <name>=<column>` (MCP oxygen_columns_add with prompt_key) adds it with no prompt to write. For ICP tiers, read the saved profile with `knowledge page get icp`, then add native Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt '<ICP criteria using {{evidence_column}} tokens>' --grounding workspace`. It costs 0.075 credits/row; preview with `columns run --dry-run`. `columns catalog --family classification --json` puts Classify in `data.native`; `data.entries` holds separate prompt templates that can cost more.",
218
219
  posture: "mixed",
219
220
  gatewayTools: ["oxygen_tables_create", "oxygen_tools_search", "oxygen_columns_add", "oxygen_columns_catalog", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
220
221
  gatewayCommands: ["tables create", "tools search", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft"],
@@ -273,13 +274,13 @@ export const OXYGEN_CAPABILITY_ROUTES = [
273
274
  primitive: "signals",
274
275
  owns: "Durable typed pulse, engagement, and product events resolved onto records.",
275
276
  notFor: "Message bodies, post artifacts, or ad-hoc provider webhooks as private automation state.",
276
- execution: "Read the hosted signal stream and feed subscriptions, Sequences, or Workflows.",
277
+ execution: "Read the hosted signal stream and type registry; tag a registered type with tags attach. Feed subscriptions, Sequences, or Workflows.",
277
278
  posture: "workspace_write",
278
- gatewayTools: ["oxygen_signals_list", "oxygen_signals_leads_today"],
279
- gatewayCommands: ["signals list", "signals leads-today", "signals registry"],
279
+ gatewayTools: ["oxygen_signals_list", "oxygen_signals_leads_today", "oxygen_signals_registry", "oxygen_tags_attach"],
280
+ gatewayCommands: ["signals list", "signals leads-today", "signals registry", "tags attach"],
280
281
  skills: ["oxygen-gtm", "oxygen-linkedin-marketing"],
281
282
  endpointSections: ["signals", "signup-leads"],
282
- intentTerms: ["signal", "signals", "intent", "website visit", "job change", "fundraising", "warm leads", "engager", "follower", "viewer"],
283
+ intentTerms: ["signal", "signals", "signal type", "tag signal", "intent", "website visit", "job change", "fundraising", "warm leads", "engager", "follower", "viewer"],
283
284
  },
284
285
  {
285
286
  id: "dashboards",
@@ -310,15 +311,15 @@ export const OXYGEN_CAPABILITY_ROUTES = [
310
311
  id: "ads",
311
312
  layer: "Data",
312
313
  primitive: "ads",
313
- owns: "Competitor ad intelligence today; owned-audience activation is a roadmap boundary.",
314
+ owns: "Competitor ad intelligence and saved search definitions; owned-audience activation is a roadmap boundary.",
314
315
  notFor: "Organic Posts, Publishing, or claiming unshipped audience activation.",
315
- execution: "Discover and run the hosted ad-intelligence provider operation with the normal paid-read gate.",
316
+ execution: "Save a reusable Ads search definition for free; running the hosted ad-intelligence provider operation uses the normal paid-read gate. Guide: /docs/data/ads.",
316
317
  posture: "paid_read",
317
- gatewayTools: ["oxygen_adyntel_ads_search", "oxygen_tools_search"],
318
- gatewayCommands: ["tools search"],
318
+ gatewayTools: ["oxygen_ads_searches", "oxygen_adyntel_ads_search", "oxygen_tools_search"],
319
+ gatewayCommands: ["ads searches list", "ads searches save", "tools search"],
319
320
  skills: ["oxygen-gtm"],
320
- endpointSections: [],
321
- intentTerms: ["ad", "ads", "advertising", "ad creative", "competitor ads", "ad intelligence", "meta ads", "linkedin ads"],
321
+ endpointSections: ["ads"],
322
+ intentTerms: ["ad", "ads", "advertising", "ad creative", "competitor ads", "ad intelligence", "meta ads", "linkedin ads", "saved ads search", "reusable ads search"],
322
323
  },
323
324
  {
324
325
  id: "tags",
@@ -326,10 +327,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
326
327
  primitive: "tags",
327
328
  owns: "The normalized cross-primitive label vocabulary and campaign handle.",
328
329
  notFor: "A campaign entity, private per-surface labels, or arbitrary execution state.",
329
- execution: "Read or mutate tags through the hosted union surface; rename is preview-first.",
330
+ execution: "Read or mutate tags through the hosted union surface; rename is preview-first. For saved Ads searches, save a definition with oxygen_ads_searches (CLI: ads searches save), then attach or read its tags here. Guide: /docs/workspace/tags.",
330
331
  posture: "workspace_write",
331
- gatewayTools: ["oxygen_tags_list", "oxygen_tags_get", "oxygen_tags_rename"],
332
- gatewayCommands: ["tags list", "tags get", "tags rename"],
332
+ gatewayTools: ["oxygen_tags_list", "oxygen_tags_get", "oxygen_tags_attach", "oxygen_tags_rename"],
333
+ gatewayCommands: ["tags list", "tags get", "tags attach", "tags rename"],
333
334
  skills: ["oxygen-gtm"],
334
335
  endpointSections: ["tags"],
335
336
  intentTerms: ["tag", "tags", "label", "labels", "campaign handle", "grouping", "organize campaign"],
@@ -345,7 +346,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
345
346
  // email track's "Only 'instantly' is supported" and concluded Smartlead was
346
347
  // unreachable. The governed path for a campaign the customer already runs
347
348
  // elsewhere is `sequences push-external`; it is not a Sequence.
348
- execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved). To stop a company's other leads when one of them replies, set `--company-reply-pause` on the campaign (choose the reply categories); to pause or resume a company on demand, e.g. from a workflow when a meeting is booked, use `sequences pause-company` / `resume-company`. A pause is resumable; a company do-not-contact entry is a permanent block on every campaign.",
349
+ execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority. Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport. To push table rows into an existing Smartlead, Instantly, lemlist or HeyReach campaign you already run, use `sequences push-external` (dry_run preview, then --approved). To stop a company's other leads when one of them replies, set `--company-reply-pause` on the campaign (choose the reply categories); to pause or resume a company on demand, e.g. from a workflow when a meeting is booked, use `sequences pause-company` / `resume-company`. A pause is resumable; a company do-not-contact entry is a permanent block on every campaign. Step copy takes lead columns as {{column}} and the sending person's name as {{sender_first_name}}, {{sender_last_name}} or {{sender_full_name}} (email adds {{sender_email}}), with no table column.",
349
350
  posture: "external_write",
350
351
  gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external", "oxygen_sequences_company_pause"],
351
352
  gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external", "sequences pause-company"],
@@ -356,7 +357,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
356
357
  // to workspace-access — "identity" matched the AUTH sense. The nouns a user
357
358
  // reaches for when naming a sending identity or the dialing pool have to land
358
359
  // on the group that actually owns `senders profiles` and `voice numbers`.
359
- intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign"],
360
+ // The sign-off terms are here because a 2026-09-29 blind user searched
361
+ // "preview rendered message per sender sign-off variable" and landed on
362
+ // collaboration; the {{sender_*}} copy variables live on sequence steps.
363
+ intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "company replied", "pause company", "pause outreach to a company", "stop outreach to a company", "stop emailing a company", "colleagues", "do not contact a company", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call", "smartlead", "instantly", "lemlist", "heyreach", "external sequencer", "push to campaign", "push rows to campaign", "add leads to campaign", "sign-off", "sign off", "signature", "sender name", "sender first name", "sender variable", "sender_first_name"],
360
364
  },
361
365
  {
362
366
  id: "publishing",
@@ -565,12 +569,13 @@ export function inferCapabilityRoute(query) {
565
569
  // beside them in `catalogIntent`, never inside them.
566
570
  const catalogue = catalogRow && CATALOGUE_CARD_IDS.has(card.id) ? catalogueRecommendations(catalogRow, card) : null;
567
571
  return {
568
- card,
572
+ card: routeCardFor(card, normalized),
569
573
  recommendedTools: recommendations.tools,
570
574
  recommendedCommands: recommendations.commands,
571
575
  ...(recommendations.exactCommand ? { exactCommand: recommendations.exactCommand } : {}),
576
+ ...(recommendations.exactMcpCall ? { exactMcpCall: recommendations.exactMcpCall } : {}),
572
577
  ...(catalogue && catalogRow
573
- ? { catalogIntent: { id: catalogRow.id, label: catalogRow.label, status: catalogRow.status, group: catalogRow.group, tools: catalogue.tools, commands: catalogue.commands, exactCommand: catalogue.exactCommand } }
578
+ ? { catalogIntent: { id: catalogRow.id, label: catalogRow.label, status: catalogRow.status, group: catalogRow.group, tools: catalogue.tools, commands: catalogue.commands, exactCommand: catalogue.exactCommand, exactMcpCall: catalogue.exactMcpCall } }
574
579
  : {}),
575
580
  };
576
581
  }
@@ -587,13 +592,25 @@ function cardForCatalogueIntent(row) {
587
592
  return ROUTE_BY_ID.get("signals") ?? ROUTE_BY_PRIMITIVE.get("signals") ?? null;
588
593
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
589
594
  }
595
+ /**
596
+ * The MCP tool a catalogue row's `find` command maps to, named by its command
597
+ * words alone: "find company --domain <domain> --fields funding,..." once became
598
+ * the tool "oxygen_find_company_domain_<domain>_fields_...", which does not
599
+ * exist (funding, hiring-signal and revenue rows, 2026-09-29).
600
+ */
601
+ export function catalogueFindTool(row) {
602
+ const command = row.surfaces.find?.split(/\s+--/)[0]?.trim();
603
+ return command ? `oxygen_${command.replace(/[\s-]+/g, "_")}` : null;
604
+ }
590
605
  function catalogueRecommendations(row, card) {
591
606
  const exactCommand = `oxygen enrichment catalog --intent ${row.id}`;
607
+ const exactMcpCall = catalogueIntentMcpCall(row.id);
592
608
  if (row.group === "decision_maker" || card.id === "sourcing-and-provider-tools") {
593
609
  return {
594
610
  tools: ["oxygen_enrichment_catalog", "oxygen_people_search_plan", "oxygen_people_search_run"],
595
611
  commands: ["enrichment catalog", "people search plan", "people search run"],
596
612
  exactCommand,
613
+ exactMcpCall,
597
614
  };
598
615
  }
599
616
  if (row.group === "signal") {
@@ -601,13 +618,16 @@ function catalogueRecommendations(row, card) {
601
618
  tools: ["oxygen_enrichment_catalog", "oxygen_signals_search_plan", "oxygen_signals_search_run"],
602
619
  commands: ["enrichment catalog", "signals search plan", "signals search run"],
603
620
  exactCommand,
621
+ exactMcpCall,
604
622
  };
605
623
  }
606
- const find = row.surfaces.find ? [`oxygen_${row.surfaces.find.replace(/[\s-]+/g, "_")}`] : [];
624
+ const findTool = catalogueFindTool(row);
625
+ const find = findTool ? [findTool] : [];
607
626
  return {
608
627
  tools: ["oxygen_enrichment_catalog", "oxygen_columns_add", "oxygen_enrich_column_preview", ...find],
609
628
  commands: ["enrichment catalog", "columns add", "enrich-column preview", ...(row.surfaces.find ? [row.surfaces.find] : [])],
610
629
  exactCommand,
630
+ exactMcpCall,
611
631
  };
612
632
  }
613
633
  export function inferPrimitiveRoute(query) {
@@ -616,7 +636,7 @@ export function inferPrimitiveRoute(query) {
616
636
  return null;
617
637
  return route;
618
638
  }
619
- export function serializeCapabilityRoute(route) {
639
+ export function serializeCapabilityRoute(route, surface = "cli") {
620
640
  if (!route)
621
641
  return null;
622
642
  return {
@@ -631,9 +651,23 @@ export function serializeCapabilityRoute(route) {
631
651
  endpoint_sections: route.card.endpointSections,
632
652
  recommended_commands: route.recommendedCommands,
633
653
  recommended_tools: route.recommendedTools,
634
- ...(route.exactCommand ? { exact_command: route.exactCommand } : {}),
654
+ ...(surface === "mcp" && route.exactMcpCall
655
+ ? { exact_mcp_call: route.exactMcpCall }
656
+ : route.exactCommand ? { exact_command: route.exactCommand } : {}),
635
657
  ...(route.catalogIntent
636
- ? { catalog_intent: { id: route.catalogIntent.id, label: route.catalogIntent.label, status: route.catalogIntent.status, group: route.catalogIntent.group, tools: route.catalogIntent.tools, commands: route.catalogIntent.commands, exact_command: route.catalogIntent.exactCommand } }
658
+ ? {
659
+ catalog_intent: {
660
+ id: route.catalogIntent.id,
661
+ label: route.catalogIntent.label,
662
+ status: route.catalogIntent.status,
663
+ group: route.catalogIntent.group,
664
+ tools: route.catalogIntent.tools,
665
+ commands: route.catalogIntent.commands,
666
+ ...(surface === "mcp"
667
+ ? { exact_mcp_call: route.catalogIntent.exactMcpCall }
668
+ : { exact_command: route.catalogIntent.exactCommand }),
669
+ },
670
+ }
637
671
  : {}),
638
672
  hydrate: {
639
673
  cli: "oxygen commands get <exact-command> --json",
@@ -669,6 +703,70 @@ function isUgcProgramIntent(query) {
669
703
  function isUgcCreatorSideIntent(query) {
670
704
  return /\b(join|apply|applying|find|discover|browse|accept)\w*\b.{0,30}\b(creator|ugc|ambassador) programs?\b/.test(query);
671
705
  }
706
+ /** The lifecycle verb of an operator's program ask ("archive program", "delete my creator program"), or null. */
707
+ function ugcProgramLifecycleVerb(query) {
708
+ return /\b(archive|restore|delete|remove|duplicate|copy|clone) (?:(?:a|an|the|this|that|my|our|old|archived|creator|ugc|brand|ambassador) ){0,3}programs?\b/.exec(query)?.[1] ?? null;
709
+ }
710
+ /** The `ugc programs` leaf and MCP tool per lifecycle verb; delete previews first and names archive when refused. */
711
+ const UGC_PROGRAM_LIFECYCLE = {
712
+ archive: ["ugc programs archive", "oxygen_ugc_program_update"],
713
+ restore: ["ugc programs restore", "oxygen_ugc_program_update"],
714
+ delete: ["ugc programs delete", "oxygen_ugc_program_delete"],
715
+ remove: ["ugc programs delete", "oxygen_ugc_program_delete"],
716
+ duplicate: ["ugc programs duplicate", "oxygen_ugc_program_duplicate"],
717
+ copy: ["ugc programs duplicate", "oxygen_ugc_program_duplicate"],
718
+ clone: ["ugc programs duplicate", "oxygen_ugc_program_duplicate"],
719
+ };
720
+ /**
721
+ * A creator's own writing voice: `ugc voice import` learns it from their LinkedIn
722
+ * history (the web's "Learn your writing voice") and `ugc voice save` keeps what
723
+ * they write themselves. Only an ask that names the creator context routes here:
724
+ * "learn my writing voice from my LinkedIn messages" is just as likely the
725
+ * workspace voice `knowledge synthesize --kind voice` learns from sent LinkedIn and
726
+ * WhatsApp messages, and brand voice stays with the Knowledge Graph.
727
+ */
728
+ function isUgcVoiceIntent(query) {
729
+ return /\bcreators?'?s? voice\b/.test(query)
730
+ || (/\bvoice\b/.test(query) && /\b(creator|ugc|ambassador)s?\b/.test(query) && !/\bbrand voice\b/.test(query));
731
+ }
732
+ /**
733
+ * Who approves a creator's post. It is the creator, or the program organizer when
734
+ * the creator delegated approval; a brand can never require its own approval of
735
+ * creator posts (Philipp, 2026-09-29).
736
+ * The MCP acceptance run's "brand approval of creator post before publish" routed
737
+ * to the Knowledge Graph, so the rule it needed never reached it. Only an
738
+ * approval word next to a creator's post counts: "find LinkedIn creator posts
739
+ * about AI" stays a keyword post search.
740
+ */
741
+ function isUgcPostApprovalIntent(query) {
742
+ return /\b(?:approv\w*|review\w*|sign[- ]?off)\b.{0,40}\b(?:creator|ugc|ambassador)s?'?\s+posts?\b/.test(query)
743
+ || /\b(?:creator|ugc|ambassador)s?'?\s+posts?\b.{0,40}\b(?:approv\w*|review\w*|sign[- ]?off)\b/.test(query)
744
+ || /\bbrand (?:approval|review)\b.{0,40}\b(?:creators?|ugc|ambassadors?)\b/.test(query);
745
+ }
746
+ function isUgcIntent(query) {
747
+ return isUgcProgramIntent(query) || isUgcCreatorSideIntent(query) || isUgcVoiceIntent(query)
748
+ || isUgcPostApprovalIntent(query) || ugcProgramLifecycleVerb(query) !== null;
749
+ }
750
+ /**
751
+ * UGC is Publishing's creator-program subpage (OXYGEN_PRODUCT.md § UGC programs): a
752
+ * creator-program ask keeps the publishing id, primitive, endpoint sections and
753
+ * gateways, but must not read Publishing's post-scheduler ownership back (UGC E2E pass, 2026-09-29).
754
+ */
755
+ const UGC_ROUTE_VIEW = {
756
+ owns: "UGC creator programs: brand programs, creator invitations and applications, participations, creator voice, program post drafts and approvals, sponsorship, results, and bounded amplification.",
757
+ notFor: "Scheduling the workspace's own posts, one-to-one messaging, outreach campaigns, or keyword post search.",
758
+ execution: "Brands create a program from its product URL and invite creators; creators apply or accept in their own workspace. A post publishes only after the creator approves its exact content, or the program organizer does when the creator delegated approval; brands cannot require approval. Paid drafts, voice imports and amplification preview first and need an approved credit ceiling.",
759
+ posture: "mixed",
760
+ skills: ["oxygen-ugc", "oxygen-linkedin-marketing"],
761
+ };
762
+ /** The card a route reports: Publishing's own, or its UGC view for a creator-program ask. */
763
+ function routeCardFor(card, query) {
764
+ if (card.primitive === "publishing" && isUgcIntent(query))
765
+ return { ...card, ...UGC_ROUTE_VIEW };
766
+ if (card.id === "knowledge-graph" && isWorkspaceVoiceIntent(query))
767
+ return { ...card, execution: WORKSPACE_VOICE_EXECUTION };
768
+ return card;
769
+ }
672
770
  function isLinkedInKeywordPostIntent(query) {
673
771
  return /\blinkedin\b/.test(query)
674
772
  && /\b(posts?|posting)\b/.test(query)
@@ -700,6 +798,104 @@ function isOwnLinkedInNetworkIntent(query) {
700
798
  const bothHalves = /\bconnections?\b/.test(query) && /\bfollowers?\b/.test(query);
701
799
  return (ownNetwork || bothHalves) && !isNetNewLinkedInInitiation(query);
702
800
  }
801
+ /**
802
+ * The workspace's own voice. `knowledge synthesize --kind voice` learns a writing
803
+ * voice from the workspace's SENT LinkedIn and WhatsApp messages; brand voice is a
804
+ * wiki page. "learn my writing voice from my LinkedIn history" scored higher on
805
+ * the connected-LinkedIn card ("my linkedin") and was offered `linkedin network
806
+ * setup` (UGC E2E pass, 2026-09-29, OXP-1.15.12). A creator's voice is UGC and is
807
+ * claimed first (isUgcVoiceIntent); phone voice (numbers, calls) never matches.
808
+ * The blind baseline also searched "writing style" and "voice learn".
809
+ */
810
+ function isWorkspaceVoiceIntent(query) {
811
+ if (/\b(?:calls?|calling|dial\w*|phone|softphone|voicemail|numbers?)\b/.test(query))
812
+ return false;
813
+ return /\b(?:writing (?:voice|style)|brand voice|tone of voice|voice guide|(?:my|our) (?:own )?voice|(?:write|sound)s? like (?:me|us))\b/.test(query)
814
+ || /\b(?:learn|capture|mimic|copy|match)\w*\b.{0,30}\bvoice\b/.test(query)
815
+ || /\bvoice\b.{0,30}\b(?:learn|capture|mimic|copy|match)\w*\b/.test(query);
816
+ }
817
+ const WORKSPACE_VOICE_EXECUTION = "A writing voice is learned from the workspace's own sent LinkedIn and WhatsApp messages: `oxygen knowledge synthesize --kind voice --json` (MCP: oxygen_context_synthesize with kind voice) runs one low-cost AI call, priced as one low-reasoning AI column cell, the preview included, and reports credits_used; to pin the guide a preview showed, pass its body back with --approved --body (no new AI call), because --approved alone synthesizes and charges again. The pinned guide is the canonical voice at once and grounds message, reply, sequence and post drafts and AI columns. It reads sent messages only, not posts, emails or web pages. Brand voice and tone live in wiki pages (knowledge search). A creator's voice in a UGC program is `ugc voice import`.";
818
+ /** One schedule for every channel of a Sequence, the same as Launch's Sending schedule. */
819
+ export const SEQUENCE_SENDING_SCHEDULE_COMMAND = "oxygen sequences update <sequence> --schedule-timezone <IANA zone> --schedule-days mon-fri --schedule-hours-start 09:00 --schedule-hours-end 17:00 --json";
820
+ /** Launch's unsubscribe switch: the visible link and the one-click headers together. */
821
+ export const SEQUENCE_UNSUBSCRIBE_COMMAND = "oxygen sequences update <sequence> --include-unsubscribe --json";
822
+ /**
823
+ * Every exact CLI command a route hands out, as the MCP tool call that sends the
824
+ * same request. The MCP surface serves these in place of the command: an MCP
825
+ * agent handed a CLI command had to translate it (blind user eval, 2026-09-29).
826
+ * Each was traced to the same `/api/cli/*` request as its command; the
827
+ * mcp-server test proves every tool exists and declares every argument.
828
+ * Factories, so a caller can edit the object it gets.
829
+ */
830
+ export const CAPABILITY_ROUTE_MCP_CALLS = {
831
+ connectedAppsList: () => ({ tool: "oxygen_connector_list", arguments: {} }),
832
+ sequenceSendingSchedule: () => ({
833
+ tool: "oxygen_sequences_update",
834
+ arguments: { sequence: "<sequence>", sending_schedule: { timezone: "<IANA zone>", days: [1, 2, 3, 4, 5], start: "09:00", end: "17:00" } },
835
+ }),
836
+ sequenceUnsubscribe: () => ({ tool: "oxygen_sequences_update", arguments: { sequence: "<sequence>", include_unsubscribe: true } }),
837
+ ugcProgramCreate: () => ({ tool: "oxygen_ugc_program_create", arguments: { product_url: "<your product site>" } }),
838
+ // The first comment rides in `content`, as the CLI's --first-comment does.
839
+ publishingFirstComment: () => ({
840
+ tool: "oxygen_publishing_posts_create",
841
+ arguments: { content_text: "<post>", publish_at: "<iso>", sender_account_id: "<sender_id>", content: { first_comment: "<comment, e.g. the link>" } },
842
+ }),
843
+ companyUrlRecovery: () => ({
844
+ tool: "oxygen_companies_search_plan",
845
+ arguments: { prompt: "Find the websites for these companies: <company names>", source_intent: "url_recovery" },
846
+ }),
847
+ // `records` is a number the tool drops when it is not one; 1000 is its default.
848
+ companyPageFollowers: () => ({
849
+ tool: "oxygen_tables_followers",
850
+ arguments: { action: "preview", company_url: "<linkedin company page URL>", records: 1000 },
851
+ }),
852
+ localBusinessScrape: () => ({
853
+ tool: "oxygen_companies_search_preview",
854
+ arguments: { action: "preview", source: "google_maps", filters: { q: "<category> in <city, region>" } },
855
+ }),
856
+ // Like the CLI command, it leaves the LinkedIn risk acknowledgement to the user.
857
+ senderConnect: () => ({ tool: "oxygen_senders_connect", arguments: { country: "<cc>" } }),
858
+ rowClassification: () => ({ tool: "oxygen_columns_catalog", arguments: { family: "classification" } }),
859
+ // The tool has no `family`: it always searches linkedin_posts, which the API reads as the same source.
860
+ linkedinKeywordPosts: () => ({
861
+ tool: "oxygen_signals_search_preview",
862
+ arguments: {
863
+ action: "preview",
864
+ filters: { keywords: ["<any of>"], all_keywords: ["<all of>"], exclude_keywords: ["<none of>"], ai_filter: "<posts to keep>" },
865
+ },
866
+ }),
867
+ };
868
+ /** The catalogue row's `enrichment catalog --intent <id>` as an MCP call. */
869
+ export function catalogueIntentMcpCall(intentId) {
870
+ return { tool: "oxygen_enrichment_catalog", arguments: { intent: intentId } };
871
+ }
872
+ // A Sequence's sending schedule or send window. Workflow language still wins
873
+ // ("a workflow that changes the send window nightly" is automation), and
874
+ // "sending hours" stays with the connected account's working hours.
875
+ function isSequenceScheduleIntent(query) {
876
+ return /\bsend(?:ing)? (?:schedule|window)s?\b/.test(query)
877
+ && !/\b(workflow|automation|automate|webhook|cron)\b/.test(query);
878
+ }
879
+ // The unsubscribe option on a Sequence's emails, not the list of people who
880
+ // opted out: a list, import or do-not-contact ask stays with suppressions.
881
+ function isSequenceUnsubscribeIntent(query) {
882
+ return /\bunsubscribe\b/.test(query)
883
+ && !/\b(workflow|automation|automate|webhook|cron)\b/.test(query)
884
+ && !/\b(unsubscribed|lists?|import\w*|suppress\w*|block ?list|blocklist|do not contact|dnc)\b/.test(query);
885
+ }
886
+ /** A post's first comment is set with the post; `publishing posts update` takes the same flag. */
887
+ export const PUBLISHING_FIRST_COMMENT_COMMAND = 'oxygen publishing posts create --publish-at <iso> --sender <sender_id> --text "<post>" --first-comment "<comment, e.g. the link>" --json';
888
+ /**
889
+ * The comment OXYGEN posts right after a post goes out ("put the link in the first
890
+ * comment") is part of the post, not a reply to one. The 2026-09-29 baselines found
891
+ * no flag, guessed `content.first_comment`, and searched in vain.
892
+ */
893
+ function isFirstCommentIntent(query) {
894
+ return /\bfirst comments?\b/.test(query)
895
+ && !/\b(repl(?:y|ies|ied)|respond\w*|answer\w*)\b/.test(query)
896
+ // Reading comments people left is owned-post comment work, not setting one.
897
+ && !/\b(show|list|read|see|view|who|whose)\b/.test(query);
898
+ }
703
899
  export const COMPANY_PAGE_FOLLOWERS_COMMAND = "oxygen tables followers check --company <linkedin company page URL> --records <n> --json";
704
900
  /**
705
901
  * "The people who follow HubSpot on LinkedIn" / "a competitor's followers" is a
@@ -732,11 +928,36 @@ function isOpenLinkedInRequestsIntent(query) {
732
928
  return /\b(connection|invitation|invite|connect) requests?\b/.test(query)
733
929
  || /\b(pending|open|unanswered|outstanding|received|sent) (linkedin )?(invitations?|invites?)\b/.test(query);
734
930
  }
931
+ /**
932
+ * The apps a person connected with OAuth and where each one works: "make
933
+ * ChatGPT always use workspace X", "default workspace for my Claude
934
+ * connection", "list my connected apps". Both blind baselines (2026-09-30)
935
+ * searched exactly this and got only `orgs select`, which changes the calling
936
+ * app or CLI and never another app; "connection" alone also pulled in the
937
+ * LinkedIn connections card.
938
+ */
939
+ function isConnectedAppsIntent(query) {
940
+ if (/\bconnected apps?\b/.test(query))
941
+ return true;
942
+ const connection = /\b(oauth (?:apps?|clients?|connections?|grants?)|mcp (?:clients?|connections?|connectors?))\b/.test(query);
943
+ const app = connection || /\b(chatgpt|claude|codex|ai (?:apps?|assistants?|clients?))\b/.test(query);
944
+ if (!app)
945
+ return false;
946
+ // An app name alone is not enough ("make Claude always use my brand voice"):
947
+ // the ask must be about which workspace it works in, or list the connections.
948
+ const workspaceChoice = /\b(workspaces?|organi[sz]ations?|orgs?)\b/.test(query)
949
+ && /\b(default|always|without asking|which|per (?:app|connection|client))\b/.test(query);
950
+ return workspaceChoice || (connection && /\b(list|show|see|which|my)\b/.test(query));
951
+ }
735
952
  function explicitCapabilityIntent(query) {
953
+ if (isConnectedAppsIntent(query))
954
+ return ROUTE_BY_ID.get("workspace-access") ?? null;
736
955
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
737
956
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
738
- if (isUgcProgramIntent(query) || isUgcCreatorSideIntent(query))
957
+ if (isUgcIntent(query))
739
958
  return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
959
+ if (isWorkspaceVoiceIntent(query))
960
+ return ROUTE_BY_ID.get("knowledge-graph") ?? null;
740
961
  if (isOwnLinkedInNetworkIntent(query) || isOpenLinkedInRequestsIntent(query))
741
962
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
742
963
  if (isCompanyPageFollowersIntent(query))
@@ -750,7 +971,7 @@ function explicitCapabilityIntent(query) {
750
971
  // names every attached channel (LinkedIn + WhatsApp + email). Resolve this
751
972
  // before public LinkedIn research, whose generic "profile" wording would
752
973
  // otherwise route an account-readiness question to scraper tools.
753
- if (isSenderProfileIntent(query)) {
974
+ if (isSenderProfileIntent(query) || isSenderCopyVariableIntent(query)) {
754
975
  return ROUTE_BY_PRIMITIVE.get("sequences") ?? null;
755
976
  }
756
977
  if (isInboxAvatarIntent(query)) {
@@ -773,6 +994,10 @@ function explicitCapabilityIntent(query) {
773
994
  // enrichment baseline).
774
995
  if (/\bprofile viewers?\b/.test(query))
775
996
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
997
+ // Ahead of the keyword-post, workflow and owned-post-comment rules below, which
998
+ // claimed "a first comment on my LinkedIn post" and "... automatically".
999
+ if (isFirstCommentIntent(query))
1000
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
776
1001
  if (isLinkedInKeywordPostIntent(query))
777
1002
  return ROUTE_BY_ID.get("signals") ?? null;
778
1003
  // Ahead of the workflow rule, whose "retry" would claim it. Why a post failed and how to get it out is Publishing's attempt ledger and
@@ -781,6 +1006,12 @@ function explicitCapabilityIntent(query) {
781
1006
  // publish" to the connected-account catalog.
782
1007
  if (isFailedPostIntent(query))
783
1008
  return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
1009
+ // When and how a Sequence sends: its sending schedule and its unsubscribe
1010
+ // option. Ahead of the workflow rule, whose bare "schedule" sent the 2026-09-29
1011
+ // unified-launch acceptance agent's "sending schedule" search to Workflows and
1012
+ // hid every Sequences command.
1013
+ if (isSequenceScheduleIntent(query) || isSequenceUnsubscribeIntent(query))
1014
+ return ROUTE_BY_PRIMITIVE.get("sequences") ?? null;
784
1015
  if (isHostedWorkflowIntent(query))
785
1016
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
786
1017
  if (isOwnedPostCommentIntent(query))
@@ -972,17 +1203,38 @@ function isLocalBusinessIntent(query) {
972
1203
  return /\b(local business(?:es)?|google maps|maps listings?|storefronts?|near me|(?:dental|medical|law|veterinary) (?:practices?|offices?|clinics?)|dentists?|restaurants?|cafes?|coffee shops?|gyms?|salons?|plumbers?|electricians?|contractors?|hvac|auto repair|real estate agents?|businesses (?:in|near|around) [a-z])\b/.test(query);
973
1204
  }
974
1205
  function recommendationsFor(card, query) {
1206
+ // A program's lifecycle and a creator's voice are UGC work the Publishing card
1207
+ // also claims ("archive program", "creator voice"); before these they got the
1208
+ // post tools (UGC E2E pass, 2026-09-29).
1209
+ const lifecycle = card.primitive === "publishing" ? UGC_PROGRAM_LIFECYCLE[ugcProgramLifecycleVerb(query) ?? ""] : undefined;
1210
+ if (lifecycle)
1211
+ return { tools: [lifecycle[1], "oxygen_ugc_get"], commands: [lifecycle[0], "ugc programs list"] };
975
1212
  if (card.primitive === "publishing" && isUgcCreatorSideIntent(query)) {
976
1213
  return {
977
1214
  tools: ["oxygen_ugc_get", "oxygen_ugc_application_create", "oxygen_ugc_invite_accept"],
978
1215
  commands: ["ugc programs discover", "ugc programs apply", "ugc creators accept"],
979
1216
  };
980
1217
  }
1218
+ if (card.primitive === "publishing" && isUgcVoiceIntent(query)) {
1219
+ return {
1220
+ tools: ["oxygen_ugc_voice_import", "oxygen_ugc_voice_save", "oxygen_ugc_get"],
1221
+ commands: ["ugc voice import", "ugc voice save", "ugc voice get"],
1222
+ };
1223
+ }
981
1224
  if (card.primitive === "publishing" && isUgcProgramIntent(query)) {
982
1225
  return {
983
1226
  tools: ["oxygen_ugc_program_create", "oxygen_ugc_invite_create", "oxygen_ugc_get"],
984
1227
  commands: ["ugc programs create", "ugc creators invite", "ugc programs list"],
985
1228
  exactCommand: "oxygen ugc programs create --product-url <your product site> --json",
1229
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.ugcProgramCreate(),
1230
+ };
1231
+ }
1232
+ if (card.primitive === "publishing" && isFirstCommentIntent(query)) {
1233
+ return {
1234
+ tools: ["oxygen_publishing_posts_create", "oxygen_publishing_posts_update", "oxygen_publishing_posts_get"],
1235
+ commands: ["publishing posts create", "publishing posts update", "publishing posts get"],
1236
+ exactCommand: PUBLISHING_FIRST_COMMENT_COMMAND,
1237
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.publishingFirstComment(),
986
1238
  };
987
1239
  }
988
1240
  if (card.id === "sourcing-and-provider-tools" && isCompanyUrlRecoveryIntent(query)) {
@@ -990,6 +1242,7 @@ function recommendationsFor(card, query) {
990
1242
  tools: ["oxygen_companies_search_plan", "oxygen_companies_search_run"],
991
1243
  commands: ["companies search plan", "companies search run"],
992
1244
  exactCommand: COMPANY_URL_RECOVERY_COMMAND,
1245
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.companyUrlRecovery(),
993
1246
  };
994
1247
  }
995
1248
  if (card.primitive === "tables" && isCompanyPageFollowersIntent(query)) {
@@ -997,6 +1250,7 @@ function recommendationsFor(card, query) {
997
1250
  tools: ["oxygen_tables_followers", "oxygen_tables_sources"],
998
1251
  commands: ["tables followers check", "tables followers order", "tables sources"],
999
1252
  exactCommand: COMPANY_PAGE_FOLLOWERS_COMMAND,
1253
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.companyPageFollowers(),
1000
1254
  };
1001
1255
  }
1002
1256
  if (card.id === "sourcing-and-provider-tools" && isLocalBusinessIntent(query)) {
@@ -1004,6 +1258,15 @@ function recommendationsFor(card, query) {
1004
1258
  tools: ["oxygen_companies_search_preview", "oxygen_companies_search_run"],
1005
1259
  commands: ["companies search filters", "companies search preview", "companies search run"],
1006
1260
  exactCommand: LOCAL_BUSINESS_SCRAPE_COMMAND,
1261
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.localBusinessScrape(),
1262
+ };
1263
+ }
1264
+ if (card.id === "workspace-access" && isConnectedAppsIntent(query)) {
1265
+ return {
1266
+ tools: ["oxygen_connector_list", "oxygen_connector_update"],
1267
+ commands: ["connectors list", "connectors set"],
1268
+ exactCommand: "oxygen connectors list --json",
1269
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.connectedAppsList(),
1007
1270
  };
1008
1271
  }
1009
1272
  if (card.id === "connected-linkedin" && isLinkedInAccountConnectIntent(query)) {
@@ -1011,6 +1274,7 @@ function recommendationsFor(card, query) {
1011
1274
  tools: ["oxygen_senders_connect", "oxygen_senders_list", "oxygen_billing_commitments"],
1012
1275
  commands: ["senders connect", "senders list", "billing commitments"],
1013
1276
  exactCommand: "oxygen senders connect --country <cc> --json",
1277
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.senderConnect(),
1014
1278
  };
1015
1279
  }
1016
1280
  if (card.id === "connected-linkedin" && isLinkedInAccountLimitsIntent(query)) {
@@ -1122,6 +1386,7 @@ function recommendationsFor(card, query) {
1122
1386
  tools: ["oxygen_columns_catalog", "oxygen_columns_add", "oxygen_columns_run", "oxygen_tables_query"],
1123
1387
  commands: ["columns catalog", "columns add", "columns run", "tables query"],
1124
1388
  exactCommand: ROW_CLASSIFICATION_COMMAND,
1389
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.rowClassification(),
1125
1390
  };
1126
1391
  }
1127
1392
  if (isCompanyResearchQuestionIntent(query)) {
@@ -1184,6 +1449,16 @@ function recommendationsFor(card, query) {
1184
1449
  }
1185
1450
  }
1186
1451
  if (card.primitive === "sequences") {
1452
+ if (isSequenceScheduleIntent(query) || isSequenceUnsubscribeIntent(query)) {
1453
+ return {
1454
+ tools: ["oxygen_sequences_update", "oxygen_sequences_get"],
1455
+ commands: ["sequences update", "sequences get"],
1456
+ exactCommand: isSequenceScheduleIntent(query) ? SEQUENCE_SENDING_SCHEDULE_COMMAND : SEQUENCE_UNSUBSCRIBE_COMMAND,
1457
+ exactMcpCall: isSequenceScheduleIntent(query)
1458
+ ? CAPABILITY_ROUTE_MCP_CALLS.sequenceSendingSchedule()
1459
+ : CAPABILITY_ROUTE_MCP_CALLS.sequenceUnsubscribe(),
1460
+ };
1461
+ }
1187
1462
  if (isSenderProfileIntent(query)) {
1188
1463
  return {
1189
1464
  tools: ["oxygen_senders_profiles_list", "oxygen_senders_profiles_get"],
@@ -1282,6 +1557,7 @@ function recommendationsFor(card, query) {
1282
1557
  tools: ["oxygen_signals_search_preview", "oxygen_signals_search_run"],
1283
1558
  commands: ["signals search preview", "signals search run"],
1284
1559
  exactCommand: LINKEDIN_KEYWORD_POST_COMMAND,
1560
+ exactMcpCall: CAPABILITY_ROUTE_MCP_CALLS.linkedinKeywordPosts(),
1285
1561
  };
1286
1562
  }
1287
1563
  if (card.primitive === "signals" && /\blinkedin\b.{0,40}\bengagers?\b/.test(query)) {
@@ -1296,6 +1572,13 @@ function recommendationsFor(card, query) {
1296
1572
  commands: ["whatsapp accounts", "whatsapp get", "whatsapp limits get", "whatsapp connect"],
1297
1573
  };
1298
1574
  }
1575
+ // Brand voice is read from its wiki page; only a writing voice is synthesized.
1576
+ if (card.id === "knowledge-graph" && isWorkspaceVoiceIntent(query) && !/\bbrand voice\b/.test(query)) {
1577
+ return {
1578
+ tools: ["oxygen_context_synthesize", "oxygen_knowledge_search", "oxygen_knowledge_page_get"],
1579
+ commands: ["knowledge synthesize", "knowledge search", "knowledge page get"],
1580
+ };
1581
+ }
1299
1582
  return { tools: [...card.gatewayTools], commands: [...card.gatewayCommands] };
1300
1583
  }
1301
1584
  // An email-VERIFICATION ask names a grading verb next to the addresses, or the
@@ -1375,6 +1658,18 @@ function isSenderProfileIntent(query) {
1375
1658
  || (/\bprofiles?\b.{0,64}\b(?:linkedin|whatsapp|mailboxes?|inboxes?|email)\b/.test(query)
1376
1659
  && /\b(?:sender|sending|unif(?:y|ied))\b/.test(query));
1377
1660
  }
1661
+ /**
1662
+ * Naming whoever sends a step inside its copy ({{sender_first_name}}, a sign-off
1663
+ * with the sender's name) is Sequence step copy. A 2026-09-29 blind user asked
1664
+ * "preview rendered message per sender sign-off variable" and was routed to
1665
+ * comments and approvals, and "sign every email with …" reads as a one-off
1666
+ * Message. A bare "sender name" stays out: renaming an inbox's From is not copy.
1667
+ */
1668
+ function isSenderCopyVariableIntent(query) {
1669
+ return /\bsender (?:first|last|full) name\b/.test(query)
1670
+ || /\bsign off (?:variable|with|per sender)\b|\bper sender sign off\b/.test(query)
1671
+ || /\bsign (?:every|each|all) (?:emails?|messages?|dms?|notes?) with\b/.test(query);
1672
+ }
1378
1673
  /** The account's own working hours or daily caps (`senders limits`), not its network. */
1379
1674
  /**
1380
1675
  * Connecting, or asking what it takes to connect, the workspace's own LinkedIn
@@ -12,7 +12,7 @@ import { type TagKind } from "./tags.js";
12
12
  * both ways: {@link EveryTagKindIsCommentable} fails the build if a taggable
13
13
  * kind is missing, and the paired test pins today's list exactly.
14
14
  */
15
- export declare const COLLAB_SUBJECT_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project", "sender_profile", "voice_number"];
15
+ export declare const COLLAB_SUBJECT_KINDS: readonly ["knowledge_page", "publishing_post", "sequence", "table", "workflow", "recipe", "conversation", "mailbox", "sender", "record", "domain", "project", "sender_profile", "voice_number", "signal_type", "dashboard", "agent", "ad_search_collection"];
16
16
  export type CollabSubjectKind = (typeof COLLAB_SUBJECT_KINDS)[number];
17
17
  /** Compile-time assertion helper: instantiating with `false` is a type error. */
18
18
  type AssertTrue<T extends true> = T;