@oxygen-agent/cli 1.987.20 → 1.1010.1

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +0 -2
  3. package/dist/admin-primary-providers-render.js +1 -1
  4. package/dist/browser-login.js +1 -4
  5. package/dist/column-decision-options.d.ts +20 -0
  6. package/dist/column-decision-options.js +54 -0
  7. package/dist/command-manifest.d.ts +3 -2
  8. package/dist/command-manifest.js +25 -2
  9. package/dist/credentials.d.ts +1 -1
  10. package/dist/functions-commands.js +13 -9
  11. package/dist/help.d.ts +8 -0
  12. package/dist/help.js +46 -0
  13. package/dist/index.js +2751 -242
  14. package/dist/knowledge-mirror.d.ts +2 -2
  15. package/dist/runtime.d.ts +0 -15
  16. package/dist/runtime.js +1 -1
  17. package/dist/search-ai-filter-notice.d.ts +17 -0
  18. package/dist/search-ai-filter-notice.js +38 -0
  19. package/dist/session.d.ts +4 -3
  20. package/dist/skills.d.ts +8 -7
  21. package/dist/skills.js +58 -20
  22. package/dist/transcript.d.ts +2 -1
  23. package/dist/util.d.ts +10 -1
  24. package/dist/util.js +14 -2
  25. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  26. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  27. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  28. package/node_modules/@oxygen/formula/dist/coerce.d.ts +10 -0
  29. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +65 -0
  31. package/node_modules/@oxygen/formula/dist/hash.d.ts +19 -0
  32. package/node_modules/@oxygen/formula/dist/hash.js +199 -0
  33. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +6 -1
  34. package/node_modules/@oxygen/formula/dist/value-cleaners.js +10 -26
  35. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  36. package/node_modules/@oxygen/shared/dist/array-utils.d.ts +5 -0
  37. package/node_modules/@oxygen/shared/dist/array-utils.js +11 -0
  38. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -22
  39. package/node_modules/@oxygen/shared/dist/billing.js +195 -40
  40. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  41. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +27 -0
  42. package/node_modules/@oxygen/shared/dist/capability-discovery.js +311 -28
  43. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  44. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  45. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  46. package/node_modules/@oxygen/shared/dist/column-autofill.d.ts +52 -0
  47. package/node_modules/@oxygen/shared/dist/column-autofill.js +62 -0
  48. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  49. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  50. package/node_modules/@oxygen/shared/dist/column-output-fields.js +2 -6
  51. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +113 -0
  52. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +548 -0
  53. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
  54. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
  55. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  56. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  57. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  58. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  59. package/node_modules/@oxygen/shared/dist/deploy-env.d.ts +74 -0
  60. package/node_modules/@oxygen/shared/dist/deploy-env.js +82 -0
  61. package/node_modules/@oxygen/shared/dist/dnc-rules.d.ts +130 -0
  62. package/node_modules/@oxygen/shared/dist/dnc-rules.js +221 -0
  63. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +107 -0
  64. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +809 -0
  65. package/node_modules/@oxygen/shared/dist/error-message.d.ts +1 -0
  66. package/node_modules/@oxygen/shared/dist/error-message.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -3
  68. package/node_modules/@oxygen/shared/dist/external-write-policy.d.ts +33 -0
  69. package/node_modules/@oxygen/shared/dist/external-write-policy.js +68 -0
  70. package/node_modules/@oxygen/shared/dist/format-percent.d.ts +8 -0
  71. package/node_modules/@oxygen/shared/dist/format-percent.js +13 -0
  72. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +81 -0
  73. package/node_modules/@oxygen/shared/dist/freemail-domains.js +157 -0
  74. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +1 -0
  75. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1 -1
  76. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  77. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  78. package/node_modules/@oxygen/shared/dist/index.d.ts +15 -0
  79. package/node_modules/@oxygen/shared/dist/index.js +15 -0
  80. package/node_modules/@oxygen/shared/dist/json-path.js +1 -3
  81. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +1 -3
  82. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +2 -2
  83. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +2 -2
  84. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  85. package/node_modules/@oxygen/shared/dist/langfuse.js +416 -14
  86. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +33 -0
  87. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +361 -0
  88. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  89. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  90. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  92. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  93. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  94. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  95. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  96. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  97. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  98. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  99. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  100. package/node_modules/@oxygen/shared/dist/log-sink-selector.d.ts +39 -0
  101. package/node_modules/@oxygen/shared/dist/log-sink-selector.js +56 -0
  102. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  103. package/node_modules/@oxygen/shared/dist/log.js +6 -1
  104. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  105. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  106. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +17 -0
  107. package/node_modules/@oxygen/shared/dist/object-storage.js +21 -0
  108. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  109. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  110. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +79 -0
  111. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +366 -0
  112. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -0
  113. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +23 -22
  114. package/node_modules/@oxygen/shared/dist/plan-limits.js +45 -18
  115. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +48 -41
  116. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +36 -25
  117. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +22 -22
  118. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +40 -34
  119. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +9 -0
  120. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +36 -2
  121. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +36 -1
  122. package/node_modules/@oxygen/shared/dist/rate-window.d.ts +5 -0
  123. package/node_modules/@oxygen/shared/dist/rate-window.js +8 -0
  124. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  125. package/node_modules/@oxygen/shared/dist/research-output-contract.js +1 -3
  126. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  127. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  128. package/node_modules/@oxygen/shared/dist/search-vocab.js +4 -5
  129. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  130. package/node_modules/@oxygen/shared/dist/sequence-failures.js +1 -5
  131. package/node_modules/@oxygen/shared/dist/sequences.d.ts +23 -0
  132. package/node_modules/@oxygen/shared/dist/sequences.js +115 -5
  133. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  134. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  135. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  136. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  137. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  138. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  139. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  140. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  141. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  142. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  143. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  144. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  145. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  146. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  147. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +22 -10
  148. package/node_modules/@oxygen/shared/dist/spend-safety.js +15 -21
  149. package/node_modules/@oxygen/shared/dist/sql-rows.d.ts +1 -0
  150. package/node_modules/@oxygen/shared/dist/sql-rows.js +3 -0
  151. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  152. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  153. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  154. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  155. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  156. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  157. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  158. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  159. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  160. package/node_modules/@oxygen/shared/dist/telemetry.js +28 -2
  161. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +22 -0
  162. package/node_modules/@oxygen/shared/dist/type-guards.js +35 -0
  163. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  164. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  165. package/node_modules/@oxygen/shared/dist/value-readers.d.ts +21 -0
  166. package/node_modules/@oxygen/shared/dist/value-readers.js +59 -0
  167. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  168. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  169. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  170. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  171. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  172. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  173. package/node_modules/@oxygen/shared/package.json +59 -0
  174. package/node_modules/@oxygen/workflows/dist/graph/expression.js +2 -5
  175. package/node_modules/@oxygen/workflows/dist/graph/params.js +1 -1
  176. package/package.json +3 -2
@@ -1,3 +1,4 @@
1
+ import { findEnrichmentIntentByAlias } from "./enrichment-intents.js";
1
2
  import { RECIPE_PRIMITIVES } from "./recipes.js";
2
3
  // One compact semantic index covers every top-level /api/cli endpoint section.
3
4
  // It is navigation, never execution: live schemas, prices, readiness, and
@@ -194,15 +195,18 @@ export const OXYGEN_CAPABILITY_ROUTES = [
194
195
  id: "records",
195
196
  layer: "Data",
196
197
  primitive: "records",
197
- owns: "Canonical companies, people, deals, identities, relationships, activities, merges, and CRM sync state.",
198
+ owns: "Canonical companies, people, deals, identities, relationships, activities, notes, tasks (reminders), files, merges, and CRM sync state.",
198
199
  notFor: "Disposable sourcing, enrichment, or scoring work; use Tables before promotion.",
199
- execution: "Operate the hosted native CRM and preserve field/relationship provenance.",
200
+ execution: "Operate the hosted native CRM and preserve field/relationship provenance. Importing FROM a connected CRM lands here via `crm sync import <provider> --object contacts|companies`, which pulls the whole object and has no list/segment filter. To import ONE HubSpot saved list or ONE Pipedrive saved filter into a new table the user can filter and enrich — optionally suppressing a do-not-contact list/filter first — use `tables hubspot import` or `tables pipedrive import` instead. Open work across the workspace is one read: `crm tasks list`.",
200
201
  posture: "workspace_write",
201
202
  gatewayTools: ["oxygen_crm_record_search", "oxygen_crm_record_get"],
202
- gatewayCommands: ["crm search", "crm get", "crm describe"],
203
+ gatewayCommands: ["crm search", "crm get", "crm describe", "crm tasks list", "crm notes list"],
203
204
  skills: ["oxygen-gtm"],
204
205
  endpointSections: ["crm"],
205
- intentTerms: ["record", "records", "crm", "company record", "contact", "person record", "deal", "timeline", "merge", "relationship"],
206
+ // "reminder"/"to-do"/"open tasks" routed to workspace-access before these
207
+ // terms existed (blind acceptance, 2026-09-22): the customer's phrasing for
208
+ // the Tasks read has to land on the primitive that owns it.
209
+ intentTerms: ["record", "records", "crm", "company record", "contact", "person record", "deal", "timeline", "merge", "relationship", "note", "notes", "task", "tasks", "reminder", "reminders", "to-do", "open tasks", "follow-up", "attachment", "files on a record"],
206
210
  },
207
211
  {
208
212
  id: "tables",
@@ -210,10 +214,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
210
214
  primitive: "tables",
211
215
  owns: "Typed working datasets, rows, formulas, AI/tool/waterfall columns, reusable Functions with isolated drafts and published versions, cell state, projects, and run provenance.",
212
216
  notFor: "Canonical CRM truth, message cadence, or an off-platform spreadsheet runtime.",
213
- execution: "Create and run work in hosted OXYGEN Tables; validate a small sample before bounded paid runs. 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.",
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.",
214
218
  posture: "mixed",
215
- gatewayTools: ["oxygen_tables_create", "oxygen_columns_add", "oxygen_columns_catalog", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
216
- gatewayCommands: ["tables create", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft"],
219
+ 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
+ gatewayCommands: ["tables create", "tools search", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft"],
217
221
  skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-linkedin-marketing"],
218
222
  endpointSections: ["action-columns", "callables", "functions", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "supabase", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
219
223
  // "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
@@ -221,12 +225,19 @@ export const OXYGEN_CAPABILITY_ROUTES = [
221
225
  // `tables create` / `columns add` / `enrich-column preview` — none of which
222
226
  // do it. The agent only found the right command by grepping the raw 25k-line
223
227
  // command manifest, which is not a discovery path a customer has.
228
+ // "hubspot list" / "saved segment" / "list import" route here for
229
+ // `tables hubspot import`. Added after the 2026-09-21 blind ACCEPTANCE run:
230
+ // the capability shipped that same day, the agent searched "import hubspot
231
+ // contacts", was routed to `records`, read `crm sync import --help`, found
232
+ // no --list flag, and reported segment-scoped import as a MISSING native
233
+ // capability. The records execution text now names the table lane too,
234
+ // because that is where this phrasing legitimately lands.
224
235
  // "founders" / "parent company" / "cloud provider" / "company research" route
225
236
  // here for `columns catalog`. Added after the 2026-09-16 blind baseline asked
226
237
  // `capabilities search "founders of a company"` and got `route: null` with a
227
238
  // generic sourcing pointer, then hand-wrote four research prompts for
228
239
  // questions the template catalog now answers by key.
229
- intentTerms: ["table", "spreadsheet", "rows", "column", "columns", "dataset", "csv", "import", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship", "function", "functions", "reusable function", "function draft", "function version", "callable", "column template", "skill set", "grad school", "podcast appearance", "keynote", "github profile", "job fit", "founders", "parent company", "subsidiaries", "cloud provider", "company research", "research question", "research column", "web research", "template catalog", "ready-made", "offers demos", "naics"],
240
+ intentTerms: ["table", "spreadsheet", "rows", "column", "columns", "dataset", "csv", "import", "hubspot list", "hubspot segment", "pipedrive filter", "pipedrive", "saved list", "saved segment", "saved filter", "crm segment", "list import", "segment import", "filter import", "import a segment", "import a list", "import a filter", "enrich", "enrichment", "waterfall", "score", "formula", "ai column", "lookup", "link", "link tables", "join", "connect", "relate", "relationship", "function", "functions", "reusable function", "function draft", "function version", "callable", "column template", "skill set", "grad school", "podcast appearance", "keynote", "github profile", "job fit", "founders", "parent company", "subsidiaries", "cloud provider", "company research", "research question", "research column", "web research", "template catalog", "ready-made", "offers demos", "naics", "cost per row", "price per row", "what can i enrich", "enrichment cost", "credits per row"],
230
241
  },
231
242
  {
232
243
  id: "messages",
@@ -274,15 +285,26 @@ export const OXYGEN_CAPABILITY_ROUTES = [
274
285
  id: "dashboards",
275
286
  layer: "Data",
276
287
  primitive: "dashboards",
277
- owns: "Curated funnel, channel, spend, and needs-action reporting over native state.",
278
- notFor: "Arbitrary BI modeling or a separate analytics store.",
279
- execution: "Compute hosted curated views over Records, Messages, Signals, Sequences, and runs.",
280
- posture: "read_only",
281
- gatewayTools: ["oxygen_dashboard_summary"],
282
- gatewayCommands: ["dashboards summary"],
288
+ owns: "The curated funnel, channel, spend and needs-action reporting, PLUS the workspace's own saved dashboards: named pages of chart / view / iframe / rich-text / code / live-source widgets over tables and CRM objects.",
289
+ notFor: "Arbitrary BI modeling or a separate analytics store. Outreach volume and replies are not chartable yet — group a table or a CRM object, or read the curated funnel with `dashboards summary`.",
290
+ execution: "Compute hosted curated views over Records, Messages, Signals, Sequences and runs; store and serve user-defined dashboards from ox_dashboards.",
291
+ // Was `read_only` until v1.1002.0, which is when a workspace could first SAVE a
292
+ // dashboard. A blind user eval (2026-09-21) caught the drift the expensive way:
293
+ // the agent asked this router for "dashboard", was told read_only and
294
+ // "not for BI modeling", and only found the eight mutating commands by
295
+ // separately searching the command index. This record is step 4 of the CLI's
296
+ // own quickstart, so a stale posture here routes users away from what shipped.
297
+ // `mixed`, not workspace_write: this capability now spans a read (summary,
298
+ // widget data), a workspace write (dashboards, tabs, widgets) AND one
299
+ // external-reach spending call (a live_source refresh calls its bound
300
+ // provider). "read_write" is not a member of CapabilityPosture -- inventing
301
+ // it is what broke the shared build on the first attempt.
302
+ posture: "mixed",
303
+ gatewayTools: ["oxygen_dashboard_summary", "oxygen_dashboards_list", "oxygen_dashboards_create", "oxygen_dashboards_widget_kinds", "oxygen_dashboards_widget_add", "oxygen_dashboards_widget_data"],
304
+ gatewayCommands: ["dashboards summary", "dashboards list", "dashboards create", "dashboards widgets kinds", "dashboards widgets add", "dashboards widgets data"],
283
305
  skills: ["oxygen-gtm"],
284
306
  endpointSections: ["dashboards"],
285
- intentTerms: ["dashboard", "report", "reporting", "funnel", "metrics", "kpi", "performance", "pipeline summary"],
307
+ intentTerms: ["dashboard", "report", "reporting", "funnel", "metrics", "kpi", "performance", "pipeline summary", "save", "saved dashboard", "custom dashboard", "widget", "chart", "weekly report", "monday", "come back to"],
286
308
  },
287
309
  {
288
310
  id: "ads",
@@ -318,10 +340,15 @@ export const OXYGEN_CAPABILITY_ROUTES = [
318
340
  primitive: "sequences",
319
341
  owns: "Net-new LinkedIn outreach plus versioned multichannel programs, cadence, enrollment, sender limits, suppression, reply-stop, and dispatch.",
320
342
  notFor: "A reply in an existing conversation or a direct one-off email; use Messages. Not for general deterministic automation.",
321
- execution: "Use the one-step gateway for new LinkedIn initiation or create/enroll/start a hosted campaign with bounded send authority.",
343
+ // The external-campaign sentence exists because a 2026-09-16 blind user asked
344
+ // to "push table rows to smartlead campaign", landed here, read the native
345
+ // email track's "Only 'instantly' is supported" and concluded Smartlead was
346
+ // unreachable. The governed path for a campaign the customer already runs
347
+ // 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. 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); the native email track of a Sequence stays Instantly-bound.",
322
349
  posture: "external_write",
323
- gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list"],
324
- gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list"],
350
+ gatewayTools: ["oxygen_sequences_send", "oxygen_sequences_create", "oxygen_sequences_enroll", "oxygen_sequences_start", "oxygen_sequences_list", "oxygen_sequences_push_external"],
351
+ gatewayCommands: ["sequences send", "sequences create", "sequences enroll", "sequences start", "sequences list", "sequences push-external"],
325
352
  skills: ["oxygen-sequencer"],
326
353
  endpointSections: ["senders", "sequencer", "sequences", "spintax", "suppressions", "voice"],
327
354
  // "sender identity", "sender profile" and "phone number" are here because a
@@ -329,7 +356,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
329
356
  // to workspace-access — "identity" matched the AUTH sense. The nouns a user
330
357
  // reaches for when naming a sending identity or the dialing pool have to land
331
358
  // on the group that actually owns `senders profiles` and `voice numbers`.
332
- intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "sender identity", "sending identity", "sender profile", "phone number", "dialing", "dialer", "call", "cold call"],
359
+ intentTerms: ["sequence", "campaign", "cadence", "enroll", "outreach", "linkedin message", "linkedin dm", "nurture", "follow up", "sender rotation", "stop on reply", "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"],
333
360
  },
334
361
  {
335
362
  id: "publishing",
@@ -346,7 +373,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
346
373
  // "remove program" / "delete program" landed on route: null in a blind eval
347
374
  // (2026-09-10); a program is a UGC noun here, and its lifecycle verbs must
348
375
  // resolve to the group that owns `ugc programs archive|delete|duplicate`.
349
- intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
376
+ intentTerms: ["publish", "publishing", "schedule post", "content calendar", "posting calendar", "approve post", "social publishing", "video cover", "video thumbnail", "cover image", "post preview", "ugc", "creator program", "user generated content", "creator voice", "sponsored creator", "ugc program", "archive program", "delete program", "remove program", "duplicate program", "copy program"],
350
377
  },
351
378
  {
352
379
  id: "workflows",
@@ -402,7 +429,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
402
429
  gatewayCommands: ["search plan", "sourcing plan", "find company", "tools search"],
403
430
  skills: ["oxygen-gtm"],
404
431
  endpointSections: ["companies", "find", "lead-sourcing", "people", "search", "sourcing", "tools", "verify"],
405
- intentTerms: ["find companies", "find people", "source leads", "build a list", "tam", "prospect", "provider", "tool catalog", "find email", "find phone"],
432
+ intentTerms: ["find companies", "find people", "source leads", "build a list", "tam", "prospect", "provider", "tool catalog", "find email", "find phone", "local businesses", "local business", "google maps", "storefronts"],
406
433
  },
407
434
  {
408
435
  id: "linkedin-public-research",
@@ -470,15 +497,15 @@ export const OXYGEN_CAPABILITY_ROUTES = [
470
497
  id: "connected-linkedin",
471
498
  layer: "External",
472
499
  primitive: null,
473
- owns: "The workspace LinkedIn account network/private data, relationship context such as mutual connections, and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
500
+ owns: "The workspace LinkedIn account network/private data — everyone you are connected to and everyone who follows you, captured into one self-refreshing 'LinkedIn Network' table — relationship context such as mutual connections, and connected-account writes used by Signals, Messages, Sequences, and Publishing.",
474
501
  notFor: "Ordinary public or third-party LinkedIn research; use the cookieless native scraper.",
475
- execution: "For mutual/shared connection counts, resolve the connected account with `oxygen senders list --json`, inspect `oxygen tools get linkedin.users_get --json`, then run `oxygen tools run linkedin.users_get --input-json '{\"identifier\":\"<profile-url>\",\"account_id\":\"<connected-account-id>\",\"notify\":false}' --mode live --json`. This read is non-notifying and no-bill. An unavailable count is explicit and never means zero; retrying the same account/profile pair is not expected to reveal a provider-hidden count. Otherwise use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
502
+ execution: "To put your own network in a table — connections and followers, one row per person with a Relationship column, kept current in the background — run `oxygen linkedin network setup --json` (add --account <ref> for one account; `oxygen linkedin network status` reports the pace and each account's state, `oxygen linkedin network pause` switches it off). Open connection requests — received and sent, on every connected account, in one table that marks each one accepted or no_longer_pending when it disappears — are `oxygen linkedin invitations setup --json` (`status` and `pause` beside it). Profile viewers are the same per account: `oxygen viewers import --account <ref>` switches one on, `oxygen viewers pause --account <ref>` off, and `oxygen viewers status` lists every account's state and `viewed_account` (whose profile each person viewed). `oxygen viewers enrichment --on` enriches each NEW viewer's full profile automatically at the per-person price `viewers status` quotes (existing viewers never). It is free, read-only on the account, and paced under its own daily read budget. For mutual/shared connection counts, resolve the connected account with `oxygen senders list --json`, inspect `oxygen tools get linkedin.users_get --json`, then run `oxygen tools run linkedin.users_get --input-json '{\"identifier\":\"<profile-url>\",\"account_id\":\"<connected-account-id>\",\"notify\":false}' --mode live --json`. This read is non-notifying and no-bill. An unavailable count is explicit and never means zero; retrying the same account/profile pair is not expected to reveal a provider-hidden count. Otherwise use the owning primitive gateway; the connected account provides identity and transport, not a competing product runtime.",
476
503
  posture: "mixed",
477
- gatewayTools: ["oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
478
- gatewayCommands: ["senders list", "connections import", "inbox list"],
504
+ gatewayTools: ["oxygen_linkedin_network_setup", "oxygen_linkedin_invitations_setup", "oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
505
+ gatewayCommands: ["linkedin network setup", "linkedin invitations setup", "senders list", "connections import", "inbox list"],
479
506
  skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
480
507
  endpointSections: ["linkedin"],
481
- intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
508
+ intentTerms: ["my linkedin", "our linkedin", "own linkedin", "connected linkedin", "connections", "mutual connections", "shared connections", "connections in common", "followers", "my network", "whole network", "linkedin network", "connections and followers", "profile viewers", "sales navigator", "recruiter", "linkedin inbox"],
482
509
  },
483
510
  {
484
511
  id: "connected-whatsapp",
@@ -498,6 +525,8 @@ export const OXYGEN_CAPABILITY_ROUTES = [
498
525
  export const OXYGEN_PRIMITIVE_ROUTES = OXYGEN_CAPABILITY_ROUTES
499
526
  .filter((card) => card.primitive !== null);
500
527
  const ROUTE_BY_ID = new Map(OXYGEN_CAPABILITY_ROUTES.map((card) => [card.id, card]));
528
+ /** Cards a catalogue row may decorate: the data surfaces, never sequences/posts/billing. */
529
+ const CATALOGUE_CARD_IDS = new Set(["tables", "sourcing-and-provider-tools", "signals"]);
501
530
  const ROUTE_BY_PRIMITIVE = new Map(OXYGEN_PRIMITIVE_ROUTES.map((card) => [card.primitive, card]));
502
531
  const NORMALIZED_PRIMITIVES = [...RECIPE_PRIMITIVES].sort();
503
532
  const NORMALIZED_ROUTE_PRIMITIVES = OXYGEN_PRIMITIVE_ROUTES.map((card) => card.primitive).sort();
@@ -523,15 +552,62 @@ export function inferCapabilityRoute(query) {
523
552
  if (!normalized)
524
553
  return null;
525
554
  const explicit = explicitCapabilityIntent(normalized);
526
- const card = explicit ?? highestScoringRoute(normalized);
555
+ const catalogRow = findEnrichmentIntentByAlias(normalized);
556
+ // Card scoring stays the owner of routing; the catalogue only breaks a
557
+ // no-match tie ("get the funding round for these accounts" names no card
558
+ // term) and decorates the data cards it belongs to.
559
+ const card = explicit ?? highestScoringRoute(normalized) ?? (catalogRow ? cardForCatalogueIntent(catalogRow) : null);
527
560
  if (!card)
528
561
  return null;
529
562
  const recommendations = recommendationsFor(card, normalized);
563
+ // The card's own recommendations are a pinned contract (tool order, no
564
+ // exact command on plain enrichment asks); the catalogue path travels
565
+ // beside them in `catalogIntent`, never inside them.
566
+ const catalogue = catalogRow && CATALOGUE_CARD_IDS.has(card.id) ? catalogueRecommendations(catalogRow, card) : null;
530
567
  return {
531
568
  card,
532
569
  recommendedTools: recommendations.tools,
533
570
  recommendedCommands: recommendations.commands,
534
571
  ...(recommendations.exactCommand ? { exactCommand: recommendations.exactCommand } : {}),
572
+ ...(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 } }
574
+ : {}),
575
+ };
576
+ }
577
+ /**
578
+ * A data-type ask ("tech stack", "decision makers at each account", "funding
579
+ * events") owns its card by taxonomy group: person/contact/company rows are
580
+ * Tables columns, decision-maker rows are the sourcing tools, signal rows are
581
+ * the signals card when one exists.
582
+ */
583
+ function cardForCatalogueIntent(row) {
584
+ if (row.group === "decision_maker")
585
+ return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
586
+ if (row.group === "signal")
587
+ return ROUTE_BY_ID.get("signals") ?? ROUTE_BY_PRIMITIVE.get("signals") ?? null;
588
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
589
+ }
590
+ function catalogueRecommendations(row, card) {
591
+ const exactCommand = `oxygen enrichment catalog --intent ${row.id}`;
592
+ if (row.group === "decision_maker" || card.id === "sourcing-and-provider-tools") {
593
+ return {
594
+ tools: ["oxygen_enrichment_catalog", "oxygen_people_search_plan", "oxygen_people_search_run"],
595
+ commands: ["enrichment catalog", "people search plan", "people search run"],
596
+ exactCommand,
597
+ };
598
+ }
599
+ if (row.group === "signal") {
600
+ return {
601
+ tools: ["oxygen_enrichment_catalog", "oxygen_signals_search_plan", "oxygen_signals_search_run"],
602
+ commands: ["enrichment catalog", "signals search plan", "signals search run"],
603
+ exactCommand,
604
+ };
605
+ }
606
+ const find = row.surfaces.find ? [`oxygen_${row.surfaces.find.replace(/[\s-]+/g, "_")}`] : [];
607
+ return {
608
+ tools: ["oxygen_enrichment_catalog", "oxygen_columns_add", "oxygen_enrich_column_preview", ...find],
609
+ commands: ["enrichment catalog", "columns add", "enrich-column preview", ...(row.surfaces.find ? [row.surfaces.find] : [])],
610
+ exactCommand,
535
611
  };
536
612
  }
537
613
  export function inferPrimitiveRoute(query) {
@@ -556,6 +632,9 @@ export function serializeCapabilityRoute(route) {
556
632
  recommended_commands: route.recommendedCommands,
557
633
  recommended_tools: route.recommendedTools,
558
634
  ...(route.exactCommand ? { exact_command: route.exactCommand } : {}),
635
+ ...(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 } }
637
+ : {}),
559
638
  hydrate: {
560
639
  cli: "oxygen commands get <exact-command> --json",
561
640
  mcp: "oxygen_capabilities_schema",
@@ -565,6 +644,23 @@ export function serializeCapabilityRoute(route) {
565
644
  function normalizeIntent(query) {
566
645
  return query.toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
567
646
  }
647
+ /**
648
+ * "LinkedIn posts about X" / "posts mentioning Y" / "monitor LinkedIn posts by
649
+ * keyword" is keyword post search, a Signals source with all-of / any-of /
650
+ * exclude rules. A blind user eval (2026-09-23) asked exactly this and was
651
+ * routed to Workflows, never learning `signals search --family linkedin_posts`
652
+ * exists. Posts FROM named profiles stay with the Profile Watcher, and the
653
+ * workspace's own posts stay with Posts. The free preview leads: it shows real
654
+ * posts, with the AI filter applied, before a table exists.
655
+ */
656
+ const LINKEDIN_KEYWORD_POST_COMMAND = 'oxygen signals search preview --family linkedin_posts --keywords "<any of>" --all-keywords "<all of>" --exclude-keywords "<none of>" --ai-filter "<posts to keep>" --json';
657
+ function isLinkedInKeywordPostIntent(query) {
658
+ return /\blinkedin\b/.test(query)
659
+ && /\b(posts?|posting)\b/.test(query)
660
+ && /\b(keywords?|about|mention\w*|topics?|niche|talk\w* about|viral|trending)\b/.test(query)
661
+ && !/\bprofiles?\b/.test(query)
662
+ && !/\b(my|our) (own )?posts?\b/.test(query);
663
+ }
568
664
  function isLinkedInProfileWatcherIntent(query) {
569
665
  return /\blinkedin\b/.test(query)
570
666
  && /\bprofiles?\b/.test(query)
@@ -572,9 +668,62 @@ function isLinkedInProfileWatcherIntent(query) {
572
668
  && (/\b(engagers?|engaging|engagement|reactions?|comments?|posts?)\b/.test(query) || /\bprofile watcher\b/.test(query))
573
669
  && !isNetNewLinkedInInitiation(query);
574
670
  }
671
+ /**
672
+ * "My LinkedIn connections and followers as a table" is the connected account's
673
+ * own network, not a Tables question. Without this the phrase "into a table"
674
+ * routed a blind user to the generic Tables card, which never names
675
+ * `linkedin network setup` (blind eval, 2026-09-22).
676
+ */
677
+ function isOwnLinkedInNetworkIntent(query) {
678
+ if (!/\blinkedin\b/.test(query))
679
+ return false;
680
+ if (/\b(mutual|shared|in common|public|third party|cookieless|post engagers?)\b/.test(query))
681
+ return false;
682
+ const ownNetwork = /\b(my|our|own|whole|entire|existing) (linkedin )?(network|connections|followers)\b/.test(query)
683
+ || /\b(everyone|everybody|people) (i am|i'm|we are|we're) connected (to|with)\b/.test(query)
684
+ || /\blinkedin network\b/.test(query);
685
+ const bothHalves = /\bconnections?\b/.test(query) && /\bfollowers?\b/.test(query);
686
+ return (ownNetwork || bothHalves) && !isNetNewLinkedInInitiation(query);
687
+ }
688
+ export const COMPANY_PAGE_FOLLOWERS_COMMAND = "oxygen tables followers check --company <linkedin company page URL> --records <n> --json";
689
+ /**
690
+ * "The people who follow HubSpot on LinkedIn" / "a competitor's followers" is a
691
+ * vendor order of a company page's audience (`tables followers`), not the
692
+ * workspace's own network and not a signal. The Signals card claims the bare
693
+ * word "follower", so without this a blind user (2026-09-24) got only
694
+ * `signals *` from `commands search "company followers"` and priced the order
695
+ * through raw `tools run` instead. The own-network ask stays with its owner.
696
+ */
697
+ function isCompanyPageFollowersIntent(query) {
698
+ const followers = /\bfollowers?\b/.test(query) || /\b(who|that) follows?\b/.test(query);
699
+ if (!followers || isOwnLinkedInNetworkIntent(query))
700
+ return false;
701
+ if (/\bfollows? (me|us)\b/.test(query) || /\b(my|our) (own )?(linkedin )?followers\b/.test(query))
702
+ return false;
703
+ return /\b(compan(y|ies)|competitors?|pages?|brands?|organi[sz]ations?)\b/.test(query)
704
+ || (/\blinkedin\b/.test(query) && /\bfollows? (?!me\b|us\b)[a-z0-9]/.test(query));
705
+ }
706
+ /**
707
+ * Open connection requests on the workspace's own accounts are the connected
708
+ * account's data. A blind user searching "pending LinkedIn connection requests"
709
+ * was routed nowhere near `linkedin invitations` (blind eval, 2026-09-24).
710
+ */
711
+ function isOpenLinkedInRequestsIntent(query) {
712
+ if (!/\blinkedin\b/.test(query))
713
+ return false;
714
+ // Sending requests to leads is Sequences, not this table.
715
+ if (/\b(send|sending|enroll|sequence|campaign|outreach|cadence)\b/.test(query))
716
+ return false;
717
+ return /\b(connection|invitation|invite|connect) requests?\b/.test(query)
718
+ || /\b(pending|open|unanswered|outstanding|received|sent) (linkedin )?(invitations?|invites?)\b/.test(query);
719
+ }
575
720
  function explicitCapabilityIntent(query) {
576
721
  if (/\b(infographic|graphic designer|render html|carousel pages|visual design|gtm flow image)\b/.test(query))
577
722
  return ROUTE_BY_ID.get("visual-rendering") ?? null;
723
+ if (isOwnLinkedInNetworkIntent(query) || isOpenLinkedInRequestsIntent(query))
724
+ return ROUTE_BY_ID.get("connected-linkedin") ?? null;
725
+ if (isCompanyPageFollowersIntent(query))
726
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
578
727
  // A whole-motion ask ("set up inbound-led outbound for my posts") is a
579
728
  // Recipe kit + playbook, not the connected-LinkedIn signals card that the
580
729
  // "my LinkedIn … engagers" rule below would otherwise claim. Resolve it first.
@@ -598,6 +747,21 @@ function explicitCapabilityIntent(query) {
598
747
  }
599
748
  if (isLinkedInProfileWatcherIntent(query))
600
749
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
750
+ // Profile viewers exist only as a connected account's data, and their capture
751
+ // owns its own on/off, pause and automatic enrichment. "Automatically enrich
752
+ // new profile viewers" reads as workflow language, but a hand-built workflow is
753
+ // the wrong answer when `viewers enrichment --on` is the switch (2026-09-23
754
+ // enrichment baseline).
755
+ if (/\bprofile viewers?\b/.test(query))
756
+ return ROUTE_BY_ID.get("connected-linkedin") ?? null;
757
+ if (isLinkedInKeywordPostIntent(query))
758
+ return ROUTE_BY_ID.get("signals") ?? null;
759
+ // 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
760
+ // unlock path: the 2026-09-25 stuck-post baseline sent "publishing failed post
761
+ // retry outcome uncertain" to Workflows and "why did my LinkedIn post fail to
762
+ // publish" to the connected-account catalog.
763
+ if (isFailedPostIntent(query))
764
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
601
765
  if (isHostedWorkflowIntent(query))
602
766
  return ROUTE_BY_PRIMITIVE.get("workflows") ?? null;
603
767
  if (isOwnedPostCommentIntent(query))
@@ -619,6 +783,17 @@ function explicitCapabilityIntent(query) {
619
783
  if (isMutualLinkedInConnectionsIntent(query)) {
620
784
  return ROUTE_BY_ID.get("connected-linkedin") ?? null;
621
785
  }
786
+ // Authoring a post's video cover or previewing a draft is Publishing work even
787
+ // when the ask says "LinkedIn post"; the public-read rule below otherwise sent
788
+ // "choose video cover frame for linkedin post" to the paid cookieless research
789
+ // catalog (blind acceptance run, 2026-09-24).
790
+ if (isPostCoverOrPreviewIntent(query))
791
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
792
+ // An X thread, poll, alt text or reply control is how an X post is authored, not
793
+ // a conversation thread (Messages): the 2026-09-25 X-formats baseline routed
794
+ // "thread" to Messages and "X poll post" nowhere.
795
+ if (isXPostFormatIntent(query))
796
+ return ROUTE_BY_PRIMITIVE.get("publishing") ?? null;
622
797
  // A deferred acquisition motion starts with the public-data owner. This keeps
623
798
  // "find/qualify now, contact later" from skipping straight to the final write
624
799
  // step; the recommendation still hands the eventual initiation to Sequences.
@@ -647,6 +822,10 @@ function explicitCapabilityIntent(query) {
647
822
  // Monday verify new signups" still lands on Workflows.
648
823
  if (isEmailVerificationIntent(query))
649
824
  return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
825
+ // "verify these phone numbers" is a verification column on a table, not a
826
+ // dialer action; without this the sequences card's call terms win.
827
+ if (/\b(verify|validate|check)\b[^.]{0,40}\b(phone|mobile)\b/.test(query))
828
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
650
829
  if (/\b(recipe|playbook|proven play|what should i do)\b/.test(query)) {
651
830
  return ROUTE_BY_PRIMITIVE.get("recipes") ?? null;
652
831
  }
@@ -655,6 +834,13 @@ function explicitCapabilityIntent(query) {
655
834
  }
656
835
  if (/\b(message )?reviews?\b/.test(query))
657
836
  return ROUTE_BY_PRIMITIVE.get("messages") ?? null;
837
+ // Sorting each row into categories ("classify companies by industry with
838
+ // confidence", "label every row as agency, SaaS or other") is a Tables
839
+ // column, not a workspace Tag. Before this rule the 2026-09-25 blind baseline
840
+ // got `route: null` for "classify companies by industry with confidence",
841
+ // and the "label" wording fell to the Tags card below.
842
+ if (isRowClassificationIntent(query))
843
+ return ROUTE_BY_PRIMITIVE.get("tables") ?? null;
658
844
  if (/\b(tag|tags|label|labels)\b/.test(query))
659
845
  return ROUTE_BY_PRIMITIVE.get("tags") ?? null;
660
846
  if (/\b(disposable|working)\b.{0,30}\b(list|dataset|table)\b|\b(prospect|sourcing) table\b/.test(query)) {
@@ -695,6 +881,12 @@ function explicitCapabilityIntent(query) {
695
881
  // company_enrich preset instead.
696
882
  if (isCompanyUrlRecoveryIntent(query))
697
883
  return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
884
+ // A category-plus-place ask ("dental practices in Austin", "local businesses",
885
+ // "scrape Google Maps") is the companies-search `local` intent. A 2026-09-21
886
+ // blind user typing "find local businesses like dental practices in a city"
887
+ // got NO route and fell back to browsing every command group's --help.
888
+ if (isLocalBusinessIntent(query))
889
+ return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
698
890
  if (/\b(find emails?|find phones?|find companies|find people|source (?:leads?|a list|companies|people)|build (?:me )?a list|tam|prospect)\b/.test(query)) {
699
891
  return ROUTE_BY_ID.get("sourcing-and-provider-tools") ?? null;
700
892
  }
@@ -749,6 +941,17 @@ function highestScoringRoute(query) {
749
941
  }
750
942
  /** The exact `companies search plan` invocation for the url_recovery intent. */
751
943
  export const COMPANY_URL_RECOVERY_COMMAND = 'oxygen companies search plan --prompt "Find the websites for these companies: <company names>" --source-intent url_recovery';
944
+ /** The free native preview for Google Maps businesses; import follows its reviewed quote. */
945
+ export const LOCAL_BUSINESS_SCRAPE_COMMAND = `oxygen companies search preview --source google_maps --filters-json '{"q":"<category> in <city, region>"}'`;
946
+ /**
947
+ * Storefronts and service businesses by category and place — the Scrape Google
948
+ * Maps Businesses table source. Named business categories are the everyday
949
+ * phrasing ("dentists in Austin"), so a short list of them is matched beside the
950
+ * generic words; "near me" and "in/around <place>" carry the same meaning.
951
+ */
952
+ function isLocalBusinessIntent(query) {
953
+ 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);
954
+ }
752
955
  function recommendationsFor(card, query) {
753
956
  if (card.id === "sourcing-and-provider-tools" && isCompanyUrlRecoveryIntent(query)) {
754
957
  return {
@@ -757,6 +960,20 @@ function recommendationsFor(card, query) {
757
960
  exactCommand: COMPANY_URL_RECOVERY_COMMAND,
758
961
  };
759
962
  }
963
+ if (card.primitive === "tables" && isCompanyPageFollowersIntent(query)) {
964
+ return {
965
+ tools: ["oxygen_tables_followers", "oxygen_tables_sources"],
966
+ commands: ["tables followers check", "tables followers order", "tables sources"],
967
+ exactCommand: COMPANY_PAGE_FOLLOWERS_COMMAND,
968
+ };
969
+ }
970
+ if (card.id === "sourcing-and-provider-tools" && isLocalBusinessIntent(query)) {
971
+ return {
972
+ tools: ["oxygen_companies_search_preview", "oxygen_companies_search_run"],
973
+ commands: ["companies search filters", "companies search preview", "companies search run"],
974
+ exactCommand: LOCAL_BUSINESS_SCRAPE_COMMAND,
975
+ };
976
+ }
760
977
  if (card.id === "connected-linkedin" && isMutualLinkedInConnectionsIntent(query)) {
761
978
  return {
762
979
  tools: ["oxygen_tools_get", "oxygen_tools_run_live", "oxygen_senders_list"],
@@ -843,6 +1060,17 @@ function recommendationsFor(card, query) {
843
1060
  commands: ["tables watcher preview", "tables watcher create", "tables watcher get", "tables watcher update", "tables watcher pause", "tables watcher resume"],
844
1061
  };
845
1062
  }
1063
+ if (isRowClassificationIntent(query)) {
1064
+ // The classification list (free) names the template production has and,
1065
+ // where the deployment runs it, the native Classify column; any other
1066
+ // scheme is a prompt column (free to add). The run's dry run prices it,
1067
+ // and the labelled rows are filtered natively with `tables query`.
1068
+ return {
1069
+ tools: ["oxygen_columns_catalog", "oxygen_columns_add", "oxygen_columns_run", "oxygen_tables_query"],
1070
+ commands: ["columns catalog", "columns add", "columns run", "tables query"],
1071
+ exactCommand: ROW_CLASSIFICATION_COMMAND,
1072
+ };
1073
+ }
846
1074
  if (isCompanyResearchQuestionIntent(query)) {
847
1075
  // A fact the template catalog already answers by key: list the keys first
848
1076
  // (free), add the column by key (free), then price and run it. Ahead of
@@ -996,6 +1224,13 @@ function recommendationsFor(card, query) {
996
1224
  };
997
1225
  }
998
1226
  }
1227
+ if (card.primitive === "signals" && isLinkedInKeywordPostIntent(query)) {
1228
+ return {
1229
+ tools: ["oxygen_signals_search_preview", "oxygen_signals_search_run"],
1230
+ commands: ["signals search preview", "signals search run"],
1231
+ exactCommand: LINKEDIN_KEYWORD_POST_COMMAND,
1232
+ };
1233
+ }
999
1234
  if (card.primitive === "signals" && /\blinkedin\b.{0,40}\bengagers?\b/.test(query)) {
1000
1235
  return {
1001
1236
  tools: ["oxygen_engagement_list_engagers", "oxygen_signals_list", "oxygen_signals_leads_today"],
@@ -1020,6 +1255,36 @@ function isEmailVerificationIntent(query) {
1020
1255
  const addressThenGrade = /\b(e ?mails?|addresses)\b.{0,40}\b(verif(?:y|ied|ication)|validat(?:e|ed|ion)|valid|invalid|deliverable|(?:will|would|might) bounce|bounce risk|risky|safe to (?:send|email)|catch ?all|accept ?all)\b/;
1021
1256
  return gradeThenAddress.test(query) || addressThenGrade.test(query);
1022
1257
  }
1258
+ /**
1259
+ * The free list of classification columns. Production answers it with its one
1260
+ * classification template (B2B or B2C); a deployment that runs the native
1261
+ * Classify column lists that entry here too. Any other scheme is an AI prompt
1262
+ * column through `columns add`, which the route recommends next.
1263
+ */
1264
+ export const ROW_CLASSIFICATION_COMMAND = "oxygen columns catalog --family classification";
1265
+ /** Nouns another primitive owns: a classify ask that names one keeps that owner. */
1266
+ const ROW_CLASSIFICATION_OTHER_OWNER = /\b(inbox|repl(?:y|ies)|conversations?|threads?|messages?|dms?|e-?mails?|bounces?|signals?|visitors?|workflows?|deals?|crm|records?|icp|knowledge|wiki|pages?|ads|dashboards?|metrics?|agents?|runs)\b/;
1267
+ /** What one table row is: the noun a per-row classification is about. */
1268
+ const ROW_CLASSIFICATION_ROW_NOUN = /\b(rows?|columns?|tables?|compan(?:y|ies)|accounts?|leads?|contacts?|domains?|people|persons?)\b/;
1269
+ /**
1270
+ * Put every row of a table into one of a few buckets: classify or categorize
1271
+ * rows, companies, accounts, leads, contacts, domains or people (or anything
1272
+ * "with confidence"), "label every row as …", or a yes/no question asked per
1273
+ * row. The verb alone is not enough: "classify my records", "categorize my
1274
+ * workflows" or "classify leads into ICP tiers" name the primitive that owns
1275
+ * them, and conversation triage ("classify my inbox replies") keeps Messages.
1276
+ * A bare "tag these contacts" stays with Tags.
1277
+ */
1278
+ function isRowClassificationIntent(query) {
1279
+ if (ROW_CLASSIFICATION_OTHER_OWNER.test(query))
1280
+ return false;
1281
+ const classifyVerb = /\b(classif(?:y|ies|ied|ying|ication)|categori[sz](?:e|es|ed|ing|ation))\b/.test(query);
1282
+ return (classifyVerb && (ROW_CLASSIFICATION_ROW_NOUN.test(query) || /\bconfidence\b/.test(query)))
1283
+ || /\blabel(?:s|led|ed|ling|ing)?\b.{0,40}\b(?:each|every|all|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?|domains?)\b/.test(query)
1284
+ || /\blabel(?:s|led|ed|ling|ing)?\b.{0,80}\bconfidence\b/.test(query)
1285
+ || /\byes(?: or |\/)no\b.{0,60}\b(?:each|every|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?)\b/.test(query)
1286
+ || /\b(?:each|every|per)\s+(?:rows?|compan(?:y|ies)|accounts?|leads?|contacts?)\b.{0,60}\byes(?: or |\/)no\b/.test(query);
1287
+ }
1023
1288
  // "get company website URL from company name", "find the websites for this
1024
1289
  // list of company names", "recover domains for company names" — a URL-ish
1025
1290
  // noun, a company noun, a recovery verb, and the hint that the input is NAMES
@@ -1104,6 +1369,24 @@ function isSecondPartyDecisionIntent(query) {
1104
1369
  && !/\b(linkedin|public|published|post|posts|publishing)\b/.test(query);
1105
1370
  return decisionLanguage || clientDecision || internalThread;
1106
1371
  }
1372
+ /** An X (Twitter) post format: thread, poll, alt text, who-can-reply, quote, photo tags. */
1373
+ function isXPostFormatIntent(query) {
1374
+ return /\b(x|twitter|tweets?)\b/.test(query)
1375
+ && /\b(thread|poll|alt text|image descriptions?|reply settings?|who can reply|quote|photo tags?|tag (?:people|someone|accounts?) in)\b/.test(query);
1376
+ }
1377
+ /** A post's video cover (thumbnail) or a preview of a post being authored. Competitor ad creative stays with Ads. */
1378
+ function isFailedPostIntent(query) {
1379
+ if (/\b(ads?|competitors?|runs?|workflows?|sequences?|tables?|comments?)\b/.test(query))
1380
+ return false;
1381
+ return /\b(posts?|tweets?)\b/.test(query)
1382
+ && /\b(fail(s|ed|ing|ure)?|stuck|locked|uncertain|refused|rejected|retry|reschedul\w*)\b/.test(query);
1383
+ }
1384
+ function isPostCoverOrPreviewIntent(query) {
1385
+ if (/\b(ads?|competitors?)\b/.test(query))
1386
+ return false;
1387
+ return (/\b(cover|thumbnail)\b/.test(query) && /\b(post|video|draft)\b/.test(query))
1388
+ || /\bpreview\b.{0,40}\b(post|draft)\b/.test(query);
1389
+ }
1107
1390
  function isOwnedPostCommentIntent(query) {
1108
1391
  if (!/\bcomments?\b/.test(query) || !/\b(linkedin|posts?|publishing)\b/.test(query))
1109
1392
  return false;
@@ -1131,7 +1414,7 @@ function isPublicLinkedInRead(query) {
1131
1414
  return false;
1132
1415
  const disclaimsConnectedAccount = /\b(without|no|not using|does not require)\b.{0,24}\bconnected(?: linkedin)? account\b/.test(query);
1133
1416
  const connectedOwnership = !disclaimsConnectedAccount
1134
- && /\b(my|our|own|owned|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers)\b/.test(query);
1417
+ && /\b(my|our|own|owned|connected|connections|followers|inbox|messages|recruiter|sales\s*navigator|salesnav|unipile|viewers?)\b/.test(query);
1135
1418
  if (connectedOwnership)
1136
1419
  return false;
1137
1420
  return /\b(comments?|companies|company|competitor|cookieless|engagers?|harvest|posts?|profiles?|public|reactions?|reactors?|scrape|scraper|search|third[ -]party)\b/.test(query);
@@ -0,0 +1,8 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ export declare class CliHttpError extends OxygenError {
3
+ readonly status: number;
4
+ constructor(status: number, code: string, message: string, options?: {
5
+ details?: unknown;
6
+ exitCode?: number;
7
+ });
8
+ }
@@ -0,0 +1,8 @@
1
+ import { OxygenError } from "./cli-result.js";
2
+ export class CliHttpError extends OxygenError {
3
+ status;
4
+ constructor(status, code, message, options = {}) {
5
+ super(code, message, options);
6
+ this.status = status;
7
+ }
8
+ }
@@ -51,6 +51,7 @@ export function exitCodeForErrorCode(code) {
51
51
  case "max_credits_required":
52
52
  case "approval_required":
53
53
  case "effect_unknown_approval_required":
54
+ case "amplification_risk_acknowledgement_required":
54
55
  case "spend_cap_required":
55
56
  case "spend_cap_too_low":
56
57
  case "insufficient_credits":