@oxygen-agent/cli 1.1011.650 → 1.1011.679

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1011.650
37
+ Version: 1.1011.679
@@ -112,6 +112,10 @@ const MUTATING_COMMANDS = new Set([
112
112
  "viewers enrichment",
113
113
  // A noun leaf that switches a bought domain's yearly paid renewal on or off.
114
114
  "domains renewal",
115
+ // The standup reads AND moves the user's Home "last seen" marker, so the next
116
+ // standup, on the web too, starts from this call. An agent assessing a
117
+ // workspace must not mistake it for a pure read (2026-10-04 blind acceptance).
118
+ "home",
115
119
  ]);
116
120
  // `--approved` usually identifies a paid or externally mutating execution
117
121
  // mode, but a small number of approved operations are explicitly
@@ -5,7 +5,7 @@ import { requestOxygen } from "./http-client.js";
5
5
  export function registerFunctionsCommands(program, handle) {
6
6
  const functions = program.command("functions")
7
7
  .description("Create, publish and reuse your workspace's Functions backed by ordinary Tables.")
8
- .addHelpText("after", "\n`functions list` shows your workspace's drafts and published Functions with per-row credit estimates. Attaching (`functions bind`) is free; only `columns run` spends, after a dry run. Use `functions duplicate <function>` to start an editable copy.\nDraft edits are isolated. Publishing updates future invocations of every caller; queued, in-flight and past runs retain their captured version. Existing caller caps never increase automatically.\nFlow: draft → describe → publish → bind → columns run --dry-run → approved run → runs.\nA bound Function executes on its own published execution table and writes named outputs back to the calling table. The call stays visible from the caller: `table-runs list --table <caller> --status all` lists the run, and the binding column's cell holds the outcome (status, outputs, credits, invocation and run ids). Address a Function by id, slug, or the display name shown by `functions list`.\nWorked example with commands: `oxygen skills install --skill oxygen-gtm --project`, then read its recipes/callable-tables.md.\n");
8
+ .addHelpText("after", "\n`functions list` shows your workspace's drafts and published Functions with per-row credit estimates. Attaching (`functions bind`) is free; only `columns run` spends, after a dry run. Use `functions duplicate <function>` to start an editable copy.\nDraft edits are isolated. Publishing updates future invocations of every caller; queued, in-flight and past runs retain their captured version. Existing caller caps never increase automatically.\nFlow: draft → describe → publish → bind → columns run --dry-run → approved run → runs.\nA bound Function executes on its own published execution table and writes named outputs back to the calling table. The call stays visible from the caller: `table-runs list --table <caller> --status all` lists the run, and the binding column's cell holds the outcome (status, outputs, credits, invocation and run ids). Address a Function by id, slug, or the display name shown by `functions list`.\nWorked example with commands: `oxygen skills get oxygen-tables --file references/functions.md --json`.\n");
9
9
  functions.command("list").description("List your workspace's drafts and published Functions with per-row credit estimates (up to 500).")
10
10
  .option("--managed", "Retired compatibility filter; returns an empty list. Use the workspace library without this flag.")
11
11
  .option("--favorites", "Only the Functions you favourited (`functions favorite <function>`).")
package/dist/help.js CHANGED
@@ -146,10 +146,13 @@ export function applyOxygenHelp(program, binaryName) {
146
146
  // first-touch surface names the skill that teaches the GTM loops.
147
147
  ` 2. ${binaryName} skills install --json load the skills that teach the GTM loops (automatic after login)`,
148
148
  " Add --project to keep the skills inside the current project, including confined agent sessions.",
149
- ` 3. ${binaryName} context resolve --json load workspace context before operating primitives`,
149
+ ` 3. ${binaryName} context resolve --purpose onboarding --json load workspace context and the next reads`,
150
150
  // Populated workspaces need the same first-win guidance as empty ones.
151
- ` Getting started or choosing what to do next? ${binaryName} skills get oxygen-onboarding --json`,
151
+ ` Getting started? ${binaryName} skills get oxygen-onboarding --json`,
152
152
  " It sharpens your offer and ICP, then ranks the warmest leads you can reach now.",
153
+ // A diagnosis is the consultant's (2026-10-04 blind acceptance: help sent
154
+ // "choosing what to do next" to onboarding, so a flat pipeline got no method).
155
+ ` Pipeline flat, or planning your GTM? ${binaryName} skills get oxygen-gtm-consultant --json`,
153
156
  ` 4. ${binaryName} capabilities search "<goal>" --json route the goal, then hydrate one exact command`,
154
157
  ` ${binaryName} commands search "<term>" --json find a command by name or purpose instead of dumping the manifest`,
155
158
  ` 5. ${binaryName} recipes list "<goal>" --view summary --json choose a play from the Recipe catalog for your goal`,
package/dist/index.js CHANGED
@@ -3780,7 +3780,7 @@ export function createProgram() {
3780
3780
  });
3781
3781
  program
3782
3782
  .command("home")
3783
- .description("Your workspace standup: what happened since you last looked, what needs you, and a year of workspace activity. Read-only, 0 credits. The same neutral composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
3783
+ .description("Your workspace standup: what happened since you last looked, what needs you, and a year of workspace activity. 0 credits. It marks Home as seen, so the next standup (here or on the web) counts from now. The same neutral composition the web Home renders, so a terminal-first operator is not sent to the browser for a status check.")
3784
3784
  .option("--json", "Print a JSON envelope.")
3785
3785
  .action(async (options) => {
3786
3786
  await handleAsyncAction("home standup", options, readHomeStandup);
@@ -5359,7 +5359,7 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
5359
5359
  body: { cpm: parseJsonObject(options.cpmJson ?? "") },
5360
5360
  }));
5361
5361
  }))
5362
- .addHelpText("after", "\nExamples (stored snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel linkedin --range 30d --sort impressions --json\n oxygen publishing analytics timeseries --channel linkedin --range 30d --tz Europe/Berlin --json\n\nTotals, leader and trends combine every posting account; `accounts` lists them and `--account <key>` counts only the ones you name. `top_posts[].account` names each post's author.\nWhy a metric is null: every null carries a reason in `null_reasons`:\n not_exposed_by_provider the rail OXYGEN currently reads through never returns it (`capabilities.metrics.<metric>.reason` names the limit)\n not_synced_yet no background snapshot has landed yet\n not_returned_by_provider snapshots exist but the provider left it out (LinkedIn shows impressions only to the post's author)\nSee also: the oxygen-linkedin-marketing product skill (`oxygen skills install`) for reading LinkedIn performance.\n"))
5362
+ .addHelpText("after", "\nExamples (stored snapshots, 0 credits, no provider call):\n oxygen publishing analytics summary --channel linkedin --range 30d --sort impressions --json\n oxygen publishing analytics timeseries --channel linkedin --range 30d --tz Europe/Berlin --json\n\nTotals, leader and trends combine every posting account; `accounts` lists them and `--account <key>` counts only the ones you name. `top_posts[].account` names each post's author.\nWhy a metric is null: every null carries a reason in `null_reasons`:\n not_exposed_by_provider the rail OXYGEN currently reads through never returns it (`capabilities.metrics.<metric>.reason` names the limit)\n not_synced_yet no background snapshot has landed yet\n not_returned_by_provider snapshots exist but the provider left it out (LinkedIn shows impressions only to the post's author)\nSee also: the oxygen-publishing product skill (`oxygen skills get oxygen-publishing --file references/analytics.md`) for reading LinkedIn performance.\n"))
5363
5363
  .addCommand(new Command("import")
5364
5364
  .description("Bulk-load scheduled posts from a .csv or .json file. Dry-run by DEFAULT: it parses, lints, and reports invalid rows while writing nothing. Pass --approved to actually write. Imported posts land as drafts that still need approval — import never approves and never publishes. A row whose idempotency_key was already used is skipped, so re-importing the same file cannot duplicate the queue.")
5365
5365
  .requiredOption("--file <path>", "Path to a .csv (header row) or .json file ([rows] or { rows: [...] }). Columns: content_text, publish_at, and optionally sender_account_id, provider, timezone, title, first_comment, tags, idempotency_key. A publish_at without Z or an offset is wall-clock time in the row's timezone, else its sender's, else UTC.")
@@ -9472,7 +9472,7 @@ Examples:
9472
9472
  ].join("\n"));
9473
9473
  program
9474
9474
  .command("recipes")
9475
- .description("Business-case GTM playbooks: proven plays with prerequisites, credit posture, and approval gates spelled out. Whole-motion operator playbooks (TAM sourcing, LinkedIn content strategy, inbound-led outbound, signal-based outbound) are the oxygen-playbooks skill: oxygen skills get oxygen-playbooks --json.")
9475
+ .description("GTM plays: proven motions with prerequisites, credit posture, and approval gates spelled out, to borrow from and adapt. To design a motion for your company, use the oxygen-gtm-consultant skill: oxygen skills get oxygen-gtm-consultant --json.")
9476
9476
  .addCommand(new Command("list")
9477
9477
  .description("Find a GTM play for a goal: recipes ranked by relevance to your words; --view summary for a compact index. Filter by business-case category, journey stage, or audience.")
9478
9478
  .argument("[query]", "Your goal in plain words, e.g. \"more pipeline from linkedin\" (ranked against title, trigger phrases, tags, and business case).")
@@ -9651,9 +9651,9 @@ Examples:
9651
9651
  }));
9652
9652
  program
9653
9653
  .command("columns")
9654
- .description("Workspace table column commands. Price first: `tools search --for-table <table>` lists every enrichment this table's columns support with its credit cost per row (0 credits). Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice and provider routing guide: the oxygen-gtm skill's enriching-and-researching.md (`oxygen skills install`). With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
9654
+ .description("Workspace table column commands. Price first: `tools search --for-table <table>` lists every enrichment this table's columns support with its credit cost per row (0 credits). Ready-made templates (pricing pages, job posts, event pages, 10-Ks, website facts, market questions, text extraction, plus free 0-credit cleanups — company names, job titles, email type, text to list; person questions over an enriched LinkedIn profile and person appearances; company research questions — founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ and footprint, LinkedIn/Crunchbase/careers page lookups): `columns catalog`, then `columns add <table> --prompt-key <key> --input <name>=<column>`. Column choice guide: the oxygen-tables skill (`oxygen skills get oxygen-tables --json`); enrichment presets and waterfalls: the oxygen-enrichment skill. With --json parse stdout only: the envelope is stdout, and the spend and cost notes (`estimated N credits for a live run`) are stderr, so merging the streams breaks the JSON.")
9655
9655
  .addCommand(new Command("add")
9656
- .description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-gtm skill's enriching-and-researching.md. For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
9656
+ .description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost. For a page, posting or filing the row already links to, or a company fact Oxygen already knows how to research (founders, parent company, funding, cloud provider, offers demos, industry, NAICS, HQ, a LinkedIn or Crunchbase page), pick a template from `columns catalog` (--prompt-key) instead of writing a prompt; or read a URL column with --kind research --research-url. The guide is the oxygen-tables skill (`oxygen skills get oxygen-tables --json`). For ICP fit, use Classify Score: `columns add <table> --label 'ICP fit' --score 'low,medium,high' --prompt 'Assess {{company_name}}: {{description}}, {{employees}} employees' --grounding workspace` (0.075 credits/row). A formula column needs no run at all: it evaluates on read from its current inputs, so `tables query` shows its values immediately.")
9657
9657
  .argument("<table>", "Table id or slug.")
9658
9658
  .option("--preset <preset>", "Add a pre-built column bundle instead of one column, which is what --prompt-key adds: a preset writes SEVERAL columns at once and needs no --input on a table whose identity column it can find, while --prompt-key adds one template column and binds each input you name. Presets: `person_enrich` (one LinkedIn profile lookup — Blitz first, the LinkedIn Scraper only when Blitz has no match — then first/last/full name, current company and its LinkedIn URL, job title, start date, education, school, headline, bio, location and followers as free stored fields that fill when the lookup runs; the person templates in `columns catalog --category people` all read its payload column), `company_enrich` (one company lookup from a domain or LinkedIn URL — Blitz first, the LinkedIn Scraper only when Blitz has no match, about 1 credit a company — keeping the whole profile in its JSON column plus name, domain, LinkedIn URL, headcount, industry, description, founded year and HQ country as free stored fields; no field choices; binds to the person preset's current_company_linkedin_url automatically), `company_tech_stack` (the technologies a company runs, from its website and hiring), `company_funding` (total raised, latest round, number of rounds and investors), `company_revenue` (an annual revenue estimate), `company_hiring` (the roles a company is hiring for, with a count) — each of these four is one paid lookup column that fills its own columns, priced per row in `tools search --for-table` — or `domain_check` (1 credit/row for a live/redirect/parked/not_found/dead verdict on every domain, cached 30 days, plus a free label column to filter on). For a single cleaned or classified column, use a template from `columns catalog` instead — that is one column, not a bundle. Name the input column with --input: `url` for person_enrich, `domain` and/or `linkedin_url` for the company presets. Any column works whatever it is called; without --input Oxygen guesses from column names. Creating the columns is free; run them afterwards, --dry-run first.")
9659
9659
  .option("--input <slot=column...>", "Bind a preset or template input to an exact column, e.g. --input url=pricing_url with --prompt-key, or --input domain=website --input linkedin_url=company_li with --preset. Repeatable. For a preset it is only needed when the automatic match is wrong or missing; for a template it names the column each declared input reads.", collectRepeatable, [])
@@ -11055,14 +11055,26 @@ Examples:
11055
11055
  await handleAsyncAction("people search filters", options, () => requestOxygen("/api/cli/people/search/preview"));
11056
11056
  }))
11057
11057
  .addCommand(new Command("preview")
11058
- .description("Preview up to 50 people free, using native filters from people search filters. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill. Scoping to companies you already keep in a table is people search run --from-table instead — one search per company, so it costs per company but covers each one.")
11058
+ .description("Preview up to 50 people free, using native filters from people search filters. To see the people at specific companies for free, put their LinkedIn company URLs in company.linkedin_url. Then people search run --source people_search --source-filters-json <same filters> for a quoted, approved table fill; people search run --from-table instead runs one paid search per company in a table and covers each one.")
11059
11059
  .requiredOption("--filters-json <json-or-file>", 'Nested native filters as JSON or @file/path, e.g. {"people":{"job_title":{"include":["VP Sales"]}},"company":{"linkedin_url":["https://linkedin.com/company/acme"]}}; {} searches all.')
11060
+ .option("--from-table <table>", "Free sample of Find decision makers at companies: search the first 50 companies in this table in one free page. Pair with --linkedin-url-column.")
11061
+ .option("--linkedin-url-column <key>", "Column of --from-table that holds each company's LinkedIn URL.")
11060
11062
  .option("--json", "Print a JSON envelope.")
11061
11063
  .action(async (options) => {
11062
- await handleAsyncAction("people search preview", options, () => requestOxygen("/api/cli/people/search/preview", {
11063
- method: "POST",
11064
- body: { filters: parseJsonObject(readFileIfPresent(options.filtersJson)) },
11065
- }));
11064
+ await handleAsyncAction("people search preview", options, async () => {
11065
+ if ((options.fromTable === undefined) !== (options.linkedinUrlColumn === undefined)) {
11066
+ throw new OxygenError("invalid_request", "--from-table and --linkedin-url-column go together.", { exitCode: 1 });
11067
+ }
11068
+ return requestOxygen("/api/cli/people/search/preview", {
11069
+ method: "POST",
11070
+ body: {
11071
+ filters: parseJsonObject(readFileIfPresent(options.filtersJson)),
11072
+ ...(options.fromTable !== undefined
11073
+ ? { source: { table: options.fromTable, linkedin_url_column: options.linkedinUrlColumn } }
11074
+ : {}),
11075
+ },
11076
+ });
11077
+ });
11066
11078
  }))
11067
11079
  .addCommand(new Command("plan")
11068
11080
  .description("Compile a people-search prompt and optional typed persona filters into ordered provider routes without provider calls.")
@@ -13232,7 +13244,7 @@ Examples:
13232
13244
  .command("verify")
13233
13245
  .description("Check whether emails you already have are safe to send to. dry_run previews the provider chain for free; live spends and needs --max-credits.")
13234
13246
  .addCommand(new Command("email")
13235
- .description("Verify one or more email addresses. MillionVerifier answers first; eligible catch-all (accept-all) addresses escalate to BounceBan when available. Returns valid / invalid / catch_all / unknown per address. If escalation cannot run, catch_all stays unconfirmed; escalation_unavailable_reason and next_action explain why. On the managed lane, Oxygen staff own dry provider balances, credential repair and hold release; workspace credits do not fix those blockers. A free preview reports an availability snapshot, not guaranteed live readiness. See https://oxygen-agent.com/docs/providers/email-verification and the oxygen-gtm MillionVerifier/BounceBan provider runbooks.")
13247
+ .description("Verify one or more email addresses. MillionVerifier answers first; eligible catch-all (accept-all) addresses escalate to BounceBan when available. Returns valid / invalid / catch_all / unknown per address. If escalation cannot run, catch_all stays unconfirmed; escalation_unavailable_reason and next_action explain why. On the managed lane, Oxygen staff own dry provider balances, credential repair and hold release; workspace credits do not fix those blockers. A free preview reports an availability snapshot, not guaranteed live readiness. See https://oxygen-agent.com/docs/providers/email-verification and the oxygen-enrichment skill (`oxygen skills get oxygen-enrichment --file references/verification.md --json`).")
13236
13248
  .argument("<emails...>", "One or more email addresses to verify.")
13237
13249
  .option("--mode <mode>", "dry_run (default) previews the plan and spends nothing; live spends credits and returns the answer.")
13238
13250
  .option("--approved", "Same as --mode live. Accepted because the global help footer names --approved as the way to authorize a credit-spending command.")
@@ -15876,11 +15888,11 @@ Examples:
15876
15888
  }, formatSequenceAnalyticsHealth);
15877
15889
  }))
15878
15890
  .addCommand(new Command("create")
15879
- .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for per-step senders, the launch preview and complete mapped CRM task examples.")
15891
+ .description("Create a draft multichannel sequence from a steps JSON file. Supports LinkedIn, email, WhatsApp, human call_task, and connected-CRM crm_task journeys. Assign LinkedIn senders with --senders (optional at create — a draft can sit senderless, but enroll/start require at least one for LinkedIn journeys). Native email sends through connected Google/Microsoft mailboxes, including Microsoft SMTP transport; optional --email-* flags bind an Instantly campaign. Install/read the oxygen-sequencer skill for per-step senders (references/email.md, references/linkedin.md), the launch ladder and complete mapped CRM task examples (references/calls-and-crm-tasks.md).")
15880
15892
  .addHelpText("after", "\nSenders: each new lead gets one of the --sender-profiles people, the one with the fewest of this sequence's leads (a lead already talking to one of your LinkedIn accounts keeps that account's person), and its LinkedIn and email steps come from that person (email from one of their inboxes). Per step, in the steps file: email_send.sender_profile_id sends that email from any inbox of one selected person (the normal inbox rotation spreads leads across that person's inboxes and each lead keeps its one; a lead already assigned to that person keeps its own inbox; with none usable the lead waits, never using a colleague's inbox), and email_send.sender_mailbox_id from one exact inbox (set one or neither); a LinkedIn step's sender_profile_id sends it from one selected person. Invite, message, inmail and withdraw_invite steps must share one sender_profile_id or none: that person then owns every lead's LinkedIn thread (LinkedIn allows one of your accounts per conversation). visit_profile, follow, like_post and comment_post may each name anyone selected. An email whose From changes, including the return to the lead's inbox, needs its own subject_template. To check who sends each step, run `sequences start <sequence>` without --approved; nothing changes.\n")
15881
15893
  .requiredOption("--name <name>", "Human-readable sequence name.")
15882
15894
  .requiredOption("--slug <slug>", "Unique slug for the sequence.")
15883
- .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, install/read the oxygen-sequencer skill.")
15895
+ .requiredOption("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] }. LinkedIn steps (visit_profile | invite | wait_for_connection | message | inmail | follow | like_post | comment_post | withdraw_invite), email steps (email_send | email_enroll | email_move | email_stop), WhatsApp (whatsapp_message), human call tasks (call_task), connected-CRM tasks (crm_task; channel crm; exact identity record_mappings + explicit create/update policy + record_links + dynamic task property_mappings/associations), and control steps (wait | wait_for_signal | branch | stop), each with an `id`. Copy templates support {{column}} interpolation from the lead's row, {{column|fallback}} inline fallbacks, deterministic spintax — {{RANDOM|a|b|c}} or industry-standard bare {Hi|Hey|Hello}, nestable like {Would {Tuesday|Thursday} work|next week?} — and {% if column %}…{% endif %} conditionals; a bare {…} region is spintax only when it contains a top-level |, so literal braces (CSS/JSON) pass through. Every step's copy can also use {{sender_first_name}}, {{sender_last_name}} and {{sender_full_name}} (alias {{sender_name}}): the name of the person who sends that step (their sender profile, else the account or inbox display name), with no table column needed; email steps add {{sender_email}} (the from address). A row column of the same name WINS on collision, and a missing name renders empty. comment_post takes text_template and/or ai_prompt — a KG-grounded comment generated at send time (a paid AI call) that falls back to text_template if generation fails. A `branch` routes on signals (then/else) or the legacy connection_accepted/already_connected/open_profile sugar (then_id/else_id). A signal condition can also branch on the LEAD'S DATA: a data leaf { has_column: \"email\" } is true when that row_values column has a non-empty value (add present:false for \"missing\") — e.g. route leads that have an email down an email arm and the rest down a LinkedIn arm. MINIMAL EMAIL STEP SHAPE: { \"id\": \"s1\", \"channel\": \"email\", \"kind\": \"email_send\", \"subject_template\": \"...\", \"body_template\": \"...\" } — body_template is REQUIRED on email_send; subject_template is OPTIONAL and is the one control that decides threading: give a step its own subject and it goes out as a NEW email, omit it and the step continues the lead's previous email in the same thread, going out as \"Re: <the previous email's subject>\" (what the retired email_reply kind used to be — still accepted on input, rewritten into this shape, and reported under `warnings` in the response). The FIRST email step of a sequence must carry a subject, since it has no earlier thread to continue. MINIMAL WHATSAPP STEP SHAPE: { \"id\": \"s1\", \"channel\": \"whatsapp\", \"kind\": \"whatsapp_message\", \"template\": \"Hi {{first_name|there}} …\" } — template is REQUIRED, and a live WhatsApp start also needs the sequence's --whatsapp-cold-initiate opt-in. MINIMAL WAIT STEP SHAPE: { \"id\": \"w1\", \"kind\": \"wait\", \"days\": 3 } — days and/or hours, at least 1 hour in total. A/B tests use an explicit `variants` array of copy partials on the step (up to 25 alternates, a–z; spintax varies wording INSIDE one variant and is not A/B-tracked). For the complete replay-safe crm_task upsert mapping shape, read `oxygen skills get oxygen-sequencer --file references/calls-and-crm-tasks.md`.")
15884
15896
  .option("--channels <list>", "Comma-separated channels: linkedin,email,whatsapp,call,crm. Defaults to the channels the journey touches. crm_task currently supports HubSpot.")
15885
15897
  .option("--whatsapp-cold-initiate", "WhatsApp: allow cold-initiating new chats (no prior conversation). Required to start a WhatsApp sequence live — WhatsApp via Unipile is unofficial WhatsApp Web, so cold-initiating is an explicit ban-risk opt-in.")
15886
15898
  .option("--phone-column-key <key>", "WhatsApp: row_values key holding each lead's phone number (else falls back to phone/phone_number/mobile).")
@@ -20032,7 +20044,7 @@ Full trigger schema: oxygen workflows schema --subject trigger --json
20032
20044
  await handleAsyncAction("skills doctor", options, () => doctorAgentSkills(options));
20033
20045
  }))
20034
20046
  .addCommand(new Command("install")
20035
- .description("Install Oxygen agent skills into local agent skill directories only; uses 0 credits and does not change the Oxygen workspace or call a provider.")
20047
+ .description("Install Oxygen agent skills into local agent skill directories only; uses 0 credits and does not change the Oxygen workspace or call a provider. Also removes retired Oxygen skills it installed earlier.")
20036
20048
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
20037
20049
  .option("--agents <agents...>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
20038
20050
  .option("--skill <skill>", "Skill name or '*'. Defaults to '*'.")
@@ -22860,7 +22872,7 @@ function readPeopleSearchRunBody(options) {
22860
22872
  // --source people_search is the Find people lane: the free preview's filters
22861
22873
  // ARE the request, so no prompt, plan or companies table is synthesized.
22862
22874
  if (options.source !== undefined && options.source !== "people_search") {
22863
- throw new OxygenError("invalid_request", "--source must be people_search; use --from-table for people at your companies.", { exitCode: 1 });
22875
+ throw new OxygenError("invalid_request", "--source must be people_search; use --from-table to find decision makers at the companies in a table.", { exitCode: 1 });
22864
22876
  }
22865
22877
  if (options.source === "people_search") {
22866
22878
  if (!options.sourceFiltersJson) {
package/dist/skills.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { dirname, join, posix, resolve } from "node:path";
5
5
  import { OxygenError, errorMessage, inferUserCapabilityRoute, serializeCapabilityRoute, toFailure, } from "@oxygen/shared";
@@ -10,6 +10,15 @@ import { resolveCmdShimInvocation } from "./windows-shim.js";
10
10
  const DEFAULT_SKILL_AGENTS = ["codex", "claude-code", "cursor"];
11
11
  const SKILL_INDEX_PATH = "/.well-known/skills/index.json";
12
12
  const SKILLS_ACCESS_API_PATH = "/api/cli/skills/access";
13
+ /** Prefix of the private temp directory every install stages skill content in. */
14
+ const STAGING_DIR_PREFIX = "oxygen-skills-";
15
+ /**
16
+ * The footer the web app's `stampAgentSkillProvenance` appends as the last line
17
+ * of every markdown file an authenticated skills grant serves. It is how an
18
+ * install proves a copy on disk came from OXYGEN before it removes one.
19
+ */
20
+ const OXYGEN_PROVENANCE_FOOTER = /^<!-- oxygen provenance: org=[0-9a-f]+ issued=\d{4}-\d{2}-\d{2} skills=\S+ -->$/;
21
+ const PROVENANCE_TAIL_BYTES = 1024;
13
22
  // Display names the vendored `npx skills` installer (1.5.x) uses in its own
14
23
  // "Failed to install" console lines (`✗ <skill> → <displayName>: <reason>`).
15
24
  // Only used to tell a genuine failure of an agent we actually requested apart
@@ -223,7 +232,8 @@ options, runtime = {}) {
223
232
  const platform = runtime.platform ?? process.platform;
224
233
  const spawn = runtime.spawnSync ?? wrapExecFileSync(runtime.execFileSync) ?? spawnSync;
225
234
  const projectDir = resolveSkillProjectDir(options.project);
226
- const installLocations = resolveSkillInstallLocations(agents, projectDir, env);
235
+ const home = resolveInstallerHome(env, platform);
236
+ const installLocations = resolveSkillInstallLocations(agents, projectDir, home, env);
227
237
  // The vendored installer writes immediately once spawned. Tell humans and
228
238
  // agents the exact destination first so a default global install is never a
229
239
  // surprise, while keeping stdout as one stable JSON envelope under --json.
@@ -244,12 +254,13 @@ options, runtime = {}) {
244
254
  // tokenized URLs stay inside this process on the fetches) and hand the
245
255
  // installer that local path instead.
246
256
  let stagingDir = null;
257
+ let retiredSkills = [];
247
258
  const outputs = [];
248
259
  const skippedTargets = [];
249
260
  try {
250
261
  let installSource = source.install_url;
251
- stagingDir = mkdtempSync(join(tmpdir(), "oxygen-skills-"));
252
- await stageAuthenticatedSkills(stagingDir, source, targets, runtime.fetch ?? fetch);
262
+ stagingDir = mkdtempSync(join(tmpdir(), STAGING_DIR_PREFIX));
263
+ retiredSkills = await stageAuthenticatedSkills(stagingDir, source, targets, runtime.fetch ?? fetch);
253
264
  installSource = stagingDir;
254
265
  for (const target of targets) {
255
266
  const args = installerAddArgs(installSource, agents, target);
@@ -311,9 +322,17 @@ options, runtime = {}) {
311
322
  // installer swept in beyond what we requested are reported distinctly and
312
323
  // never counted as a failure — see classifyInstallerFailures.
313
324
  const skippedAgents = [...new Set(skippedTargets.map((entry) => entry.agent))];
325
+ // A copy of a skill OXYGEN retired keeps loading withdrawn instructions until
326
+ // it is removed, including the installer's canonical copy that agent skill
327
+ // directories symlink to.
328
+ const retired = removeRetiredAgentSkills(retiredSkills, installerCanonicalSkillsDir(projectDir, home), installLocations.map((location) => location.path), projectDir);
314
329
  if (dependencySkills.length > 0) {
315
330
  process.stderr.write(`note: '${skill}' also installs its prerequisite skill(s): ${dependencySkills.join(", ")}.\n`);
316
331
  }
332
+ if (retired.removed.length > 0) {
333
+ const lockFiles = [...new Set(retired.removed.flatMap((entry) => entry.lock_file ? [entry.lock_file] : []))];
334
+ process.stderr.write(`note: removed retired Oxygen skill(s) installed earlier: ${retired.removed.map((entry) => entry.path).join(", ")}${lockFiles.length > 0 ? `; updated ${lockFiles.join(", ")} to match` : ""}.\n`);
335
+ }
317
336
  return {
318
337
  indexUrl: source.index_url,
319
338
  index_url: source.index_url,
@@ -335,6 +354,8 @@ options, runtime = {}) {
335
354
  // Any genuine failure threw above rather than reaching this return.
336
355
  failed_count: 0,
337
356
  summary: summarizeInstallOutcome(agents.length, skippedAgents.length, 0),
357
+ retired_skills_removed: retired.removed,
358
+ retired_skills_kept: retired.kept,
338
359
  };
339
360
  }
340
361
  /**
@@ -349,7 +370,7 @@ export function resolveSkillProjectDir(project) {
349
370
  return resolve(project.trim());
350
371
  return null;
351
372
  }
352
- function resolveSkillInstallLocations(agents, projectDir, env) {
373
+ function resolveSkillInstallLocations(agents, projectDir, home, env) {
353
374
  const project = projectDir !== null;
354
375
  const base = projectDir ?? process.cwd();
355
376
  const projectRoots = {
@@ -358,16 +379,165 @@ function resolveSkillInstallLocations(agents, projectDir, env) {
358
379
  cursor: resolve(base, ".agents", "skills"),
359
380
  };
360
381
  const globalRoots = {
361
- codex: join(env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "skills"),
362
- "claude-code": join(env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "skills"),
363
- cursor: join(homedir(), ".cursor", "skills"),
382
+ codex: join(env.CODEX_HOME?.trim() || join(home, ".codex"), "skills"),
383
+ "claude-code": join(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), "skills"),
384
+ cursor: join(home, ".cursor", "skills"),
364
385
  };
365
386
  const roots = project ? projectRoots : globalRoots;
366
387
  return agents.map((agent) => ({
367
388
  agent,
368
- path: roots[agent] ?? (project ? resolve(base, ".agents", "skills") : join(homedir(), ".agents", "skills")),
389
+ path: roots[agent] ?? installerCanonicalSkillsDir(projectDir, home),
369
390
  }));
370
391
  }
392
+ /**
393
+ * The home directory the spawned installer resolves: it runs with `env`, so its
394
+ * `os.homedir()` reads HOME (USERPROFILE on Windows) from that same environment.
395
+ */
396
+ function resolveInstallerHome(env, platform) {
397
+ return (platform === "win32" ? env.USERPROFILE : env.HOME)?.trim() || homedir();
398
+ }
399
+ /** Where `npx skills add` keeps the real copy that agent skill directories link to. */
400
+ function installerCanonicalSkillsDir(projectDir, home) {
401
+ return projectDir ? resolve(projectDir, ".agents", "skills") : join(home, ".agents", "skills");
402
+ }
403
+ /**
404
+ * Removes copies of skills OXYGEN retired, so agents stop loading instructions
405
+ * OXYGEN withdrew. A directory is removed only when its SKILL.md ends with
406
+ * OXYGEN's provenance footer. A symlink is never followed: the only one removed
407
+ * is the installer's own link to a canonical copy that is gone, which would
408
+ * otherwise stay behind as a broken skill entry. The canonical directory is
409
+ * visited first, so its copies are decided before the links to them are
410
+ * reached. Anything else found at a retired name is kept with the reason; a
411
+ * project install's canonical removals are also dropped from its installer lock.
412
+ */
413
+ function removeRetiredAgentSkills(names, canonicalDir, agentDirs, projectDir) {
414
+ const outcome = { removed: [], kept: [] };
415
+ for (const dir of new Set([canonicalDir, ...agentDirs])) {
416
+ for (const name of names) {
417
+ const path = join(dir, name);
418
+ let disposition;
419
+ try {
420
+ disposition = retiredCopyDisposition(path, resolve(canonicalDir, name));
421
+ }
422
+ catch (error) {
423
+ // Nothing is there (ENOTDIR: the skills directory itself is a file).
424
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR")
425
+ continue;
426
+ disposition = { keep: `unreadable (${errorCode(error)})` };
427
+ }
428
+ if ("remove" in disposition) {
429
+ try {
430
+ // A link is unlinked, never removed recursively.
431
+ if (disposition.remove === "link")
432
+ unlinkSync(path);
433
+ else
434
+ rmSync(path, { recursive: true, force: true });
435
+ }
436
+ catch (error) {
437
+ disposition = { keep: `removal failed (${errorCode(error)})` };
438
+ }
439
+ }
440
+ if ("keep" in disposition) {
441
+ outcome.kept.push({ name, path, reason: disposition.keep });
442
+ continue;
443
+ }
444
+ const lockFile = projectDir && dir === canonicalDir ? dropStagedLockEntry(projectDir, name) : null;
445
+ outcome.removed.push({ name, path, ...(lockFile ? { lock_file: lockFile } : {}) });
446
+ }
447
+ }
448
+ return outcome;
449
+ }
450
+ /**
451
+ * Whether to keep the entry at a retired skill's name, and why, or how to
452
+ * remove it. Throws when `path` itself cannot be lstat'ed (ENOENT: nothing is
453
+ * there).
454
+ */
455
+ function retiredCopyDisposition(path, canonicalCopy) {
456
+ const entry = lstatSync(path);
457
+ if (entry.isSymbolicLink())
458
+ return retiredLinkDisposition(path, canonicalCopy);
459
+ if (!entry.isDirectory())
460
+ return { keep: "not a directory" };
461
+ const skillFile = join(path, "SKILL.md");
462
+ try {
463
+ const file = lstatSync(skillFile);
464
+ if (!file.isFile())
465
+ return { keep: "SKILL.md is not a regular file" };
466
+ return OXYGEN_PROVENANCE_FOOTER.test(lastLineOf(skillFile, file.size))
467
+ ? { remove: "directory" }
468
+ : { keep: "SKILL.md carries no OXYGEN provenance marker" };
469
+ }
470
+ catch (error) {
471
+ return { keep: errorCode(error) === "ENOENT" ? "no SKILL.md" : `SKILL.md unreadable (${errorCode(error)})` };
472
+ }
473
+ }
474
+ /**
475
+ * A link is removable only when its target, resolved against the link's own
476
+ * directory, is exactly this skill's canonical copy and nothing is there any
477
+ * more: the installer's own link, broken once this pass or an earlier one
478
+ * removed the copy. The target is never followed, only lstat'ed.
479
+ */
480
+ function retiredLinkDisposition(link, canonicalCopy) {
481
+ let target;
482
+ try {
483
+ target = resolve(dirname(link), readlinkSync(link));
484
+ }
485
+ catch (error) {
486
+ return { keep: `symlink; unreadable (${errorCode(error)})` };
487
+ }
488
+ if (target !== canonicalCopy)
489
+ return { keep: "symlink to a path outside the installer's canonical copy" };
490
+ try {
491
+ lstatSync(target);
492
+ return { keep: "symlink to the installer's canonical copy, which is still present" };
493
+ }
494
+ catch (error) {
495
+ return errorCode(error) === "ENOENT"
496
+ ? { remove: "link" }
497
+ : { keep: `symlink; its target is unreadable (${errorCode(error)})` };
498
+ }
499
+ }
500
+ /** The last non-empty line of a file, read from its tail only. */
501
+ function lastLineOf(file, size) {
502
+ const length = Math.min(size, PROVENANCE_TAIL_BYTES);
503
+ const tail = Buffer.alloc(length);
504
+ const fd = openSync(file, "r");
505
+ try {
506
+ readSync(fd, tail, 0, length, size - length);
507
+ }
508
+ finally {
509
+ closeSync(fd);
510
+ }
511
+ return tail.toString("utf8").trimEnd().split("\n").pop()?.trim() ?? "";
512
+ }
513
+ /**
514
+ * A project install of a local source is recorded in `<project>/skills-lock.json`
515
+ * (a global one writes no lock entry). Drop a removed skill's entry only when it
516
+ * names an OXYGEN staging directory as its source, so the installer's lock stops
517
+ * listing a skill that is gone. Returns the lock file it changed, if any.
518
+ */
519
+ function dropStagedLockEntry(projectDir, name) {
520
+ const lockFile = join(projectDir, "skills-lock.json");
521
+ try {
522
+ const lock = JSON.parse(readFileSync(lockFile, "utf8"));
523
+ if (!isRecord(lock) || !isRecord(lock.skills))
524
+ return null;
525
+ const entry = lock.skills[name];
526
+ if (!isRecord(entry) || entry.sourceType !== "local" || typeof entry.source !== "string")
527
+ return null;
528
+ if (!(entry.source.split(/[\\/]/).pop() ?? "").startsWith(STAGING_DIR_PREFIX))
529
+ return null;
530
+ delete lock.skills[name];
531
+ writeFileSync(lockFile, `${JSON.stringify(lock, null, 2)}\n`);
532
+ return lockFile;
533
+ }
534
+ catch {
535
+ return null;
536
+ }
537
+ }
538
+ function errorCode(error) {
539
+ return error?.code ?? errorMessage(error);
540
+ }
371
541
  /**
372
542
  * Stages the requested skill(s) into `dir` so the installer receives a local
373
543
  * filesystem path instead of the org-bound tokenized access URL. Keeping the URL
@@ -378,10 +548,10 @@ function resolveSkillInstallLocations(agents, projectDir, env) {
378
548
  *
379
549
  * Any fetch/staging failure throws the same `skills_install_failed` error the
380
550
  * installer path raises — we never fall back to passing the tokenized URL as a
381
- * process argument.
551
+ * process argument. Returns the skill names the index lists as retired.
382
552
  */
383
553
  async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
384
- const entries = await fetchStagedSkillEntries(source.index_url, fetchImpl);
554
+ const { entries, retired } = await fetchStagedSkillEntries(source.index_url, fetchImpl);
385
555
  const wanted = targets.includes("*")
386
556
  ? entries
387
557
  : entries.filter((entry) => targets.includes(entry.name));
@@ -418,6 +588,7 @@ async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
418
588
  if (failure !== undefined)
419
589
  throw failure;
420
590
  }
591
+ return retired;
421
592
  }
422
593
  const STAGED_FILE_CONCURRENCY = 6;
423
594
  const STAGED_FILE_TIMEOUT_MS = 30_000;
@@ -460,7 +631,12 @@ async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
460
631
  }
461
632
  entries.push({ name: entry.name, files });
462
633
  }
463
- return entries;
634
+ // Older servers omit `retired`, which removes nothing. A name that is unsafe
635
+ // as a path segment, or that the index still serves, is never treated as one.
636
+ const retired = Array.isArray(parsed.retired)
637
+ ? [...new Set(parsed.retired)].filter((name) => typeof name === "string" && isSafeSkillName(name) && !entries.some((entry) => entry.name === name))
638
+ : [];
639
+ return { entries, retired };
464
640
  }
465
641
  /** A transport failure, a timeout, 429 or 5xx is retried; any other status is final. */
466
642
  async function fetchStagedSkillFile(url, fetchImpl) {
@@ -79,6 +79,16 @@ export declare function inferCapabilityRoute(query: string): CapabilityRouteMatc
79
79
  */
80
80
  export declare function catalogueFindTool(row: EnrichmentIntentTaxonomyEntry): string | null;
81
81
  export declare function inferPrimitiveRoute(query: string): PrimitiveRouteMatch | null;
82
+ /**
83
+ * Skills OXYGEN once served and has retired (2026-10-04: plays are inspiration
84
+ * read through the recipes API, never skills, and visual design is taught by
85
+ * its commands' own help; LinkedIn marketing split into the Publishing and
86
+ * Signals feature skills, with its account-safety rules in the sequencer's
87
+ * LinkedIn reference). The manifest no longer serves them, so nothing new
88
+ * can install, attach or advertise them; their audience below stays so an Agent
89
+ * version or Copilot session that already pinned one keeps its snapshot.
90
+ */
91
+ export declare const RETIRED_PRODUCT_SKILLS: readonly string[];
82
92
  /** Current hosted instruction audiences, shared by discovery and Agent snapshots. */
83
93
  export declare const OXYGEN_PRODUCT_SKILL_AUDIENCES: Readonly<Record<string, "workspace" | "agency_partner">>;
84
94
  export declare function canReadProductSkillInstructions(name: string, entitlements: {
@@ -89,7 +99,7 @@ export declare function canReadProductSkillInstructions(name: string, entitlemen
89
99
  export declare function readProductSkillInstructionResult(capabilityName: string, result: unknown): {
90
100
  slug: string | null;
91
101
  } | null;
92
- /** Public action cards never advertise invite-only or unknown skill instructions. */
102
+ /** Public action cards never advertise invite-only, retired or unknown skill instructions. */
93
103
  export declare function isPublicCapabilitySkill(name: string): boolean;
94
104
  export declare function serializeCapabilityRoute(route: CapabilityRouteMatch | null, surfaceOrOptions?: CapabilityRouteSurface | {
95
105
  visibleSkills?: readonly string[];
@@ -14,7 +14,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
14
14
  posture: "mixed",
15
15
  gatewayTools: ["oxygen_visual_source_create", "oxygen_visual_render_preview", "oxygen_visual_render_get", "oxygen_visual_image_view"],
16
16
  gatewayCommands: ["visuals source-create", "visuals render-preview", "visuals render-get", "visuals download"],
17
- skills: ["oxygen-visual-design", "oxygen-knowledge"],
17
+ skills: ["oxygen-knowledge"],
18
18
  endpointSections: ["visuals"],
19
19
  intentTerms: ["infographic", "gtm flow image", "render html", "visual design", "carousel pages", "svg", "png", "pdf export", "graphic designer"],
20
20
  },
@@ -64,13 +64,13 @@ export const OXYGEN_CAPABILITY_ROUTES = [
64
64
  id: "onboarding-and-copilot",
65
65
  layer: "Control",
66
66
  primitive: null,
67
- owns: "First-run setup and interactive Workspace Copilot sessions over the shared primitive contracts.",
67
+ owns: "First-run setup, GTM diagnosis and planning, and interactive Workspace Copilot sessions over the shared primitive contracts.",
68
68
  notFor: "Recurring deterministic automation or standing adaptive jobs; use Workflows or Agents.",
69
- execution: "Use existing onboarding context to understand the user goal and GTM bottleneck, recommend a useful next improvement, and guide its native implementation when selected.",
69
+ execution: "Use existing onboarding context to understand the user goal and GTM bottleneck, recommend a useful next improvement, and guide its native implementation when selected. For a diagnosis or a GTM plan, assess the workspace, agree the binding constraint, and design a custom motion run as a capped pilot.",
70
70
  posture: "mixed",
71
71
  gatewayTools: ["oxygen_whoami", "oxygen_recipes_list", "oxygen_blueprints_list", "oxygen_context_resolve", "oxygen_copilot_start"],
72
72
  gatewayCommands: ["whoami", "context resolve", "recipes list", "blueprints list", "copilot start"],
73
- skills: ["oxygen-onboarding", "oxygen-quickstart", "oxygen-gtm"],
73
+ skills: ["oxygen-onboarding", "oxygen-gtm-consultant", "oxygen-quickstart", "oxygen-gtm"],
74
74
  // `activation` remains an exact legacy API family for saved clients, but it
75
75
  // has no recommended gateway and is not an intent term or first-touch path.
76
76
  endpointSections: ["activation", "copilot", "home", "onboarding"],
@@ -78,7 +78,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
78
78
  // users say ("next step of this workflow run"), and a 6-point multi-word hit
79
79
  // would drag those queries here. The terms kept are the ones only a founder
80
80
  // asking where they are in setup actually types.
81
- intentTerms: ["onboard", "onboarding", "first run", "first day", "setup oxygen", "copilot", "assistant", "guided", "where am i", "next step in setup", "what do i do next", "gtm consultant", "gtm planning", "improve our gtm operation"],
81
+ intentTerms: ["onboard", "onboarding", "first run", "first day", "setup oxygen", "copilot", "assistant", "guided", "where am i", "next step in setup", "what do i do next", "gtm consultant", "gtm planning", "improve our gtm operation", "gtm review", "pipeline is flat", "go-to-market plan"],
82
82
  },
83
83
  {
84
84
  id: "billing-and-budgets",
@@ -196,7 +196,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
196
196
  posture: "workspace_write",
197
197
  gatewayTools: ["oxygen_recipes_list", "oxygen_recipes_get"],
198
198
  gatewayCommands: ["recipes list", "recipes show", "recipes apply", "recipes install"],
199
- skills: ["oxygen-recipes", "oxygen-playbooks"],
199
+ skills: ["oxygen-recipes", "oxygen-gtm-consultant"],
200
200
  endpointSections: ["blueprints", "recipes", "templates"],
201
201
  intentTerms: ["recipe", "playbook", "proven play", "activation play", "setup play", "journey", "day 1", "day 7", "day 30", "blueprint", "template", "what should i do", "inbound led outbound", "engagers into a sequence", "warm outbound from my posts", "whole motion", "end to end motion", "kit"],
202
202
  },
@@ -210,7 +210,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
210
210
  posture: "workspace_write",
211
211
  gatewayTools: ["oxygen_crm_record_search", "oxygen_crm_record_get"],
212
212
  gatewayCommands: ["crm search", "crm get", "crm describe", "crm tasks list", "crm notes list"],
213
- skills: ["oxygen-gtm"],
213
+ skills: ["oxygen-crm"],
214
214
  endpointSections: ["crm"],
215
215
  // "reminder"/"to-do"/"open tasks" routed to workspace-access before these
216
216
  // terms existed (blind acceptance, 2026-09-22): the customer's phrasing for
@@ -227,7 +227,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
227
227
  posture: "mixed",
228
228
  gatewayTools: ["oxygen_tables_create", "oxygen_tools_search", "oxygen_columns_add", "oxygen_columns_catalog", "oxygen_enrich_column_preview", "oxygen_tables_link_bulk", "oxygen_callables_manage"],
229
229
  gatewayCommands: ["tables create", "tools search", "columns add", "columns catalog", "enrich-column preview", "tables link", "functions list", "functions draft"],
230
- skills: ["oxygen-gtm", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-linkedin-marketing"],
230
+ skills: ["oxygen-tables", "oxygen-enrichment", "oxygen-table-tidy", "oxygen-diagnostics", "oxygen-clay-migration", "oxygen-signals"],
231
231
  endpointSections: ["action-columns", "callables", "functions", "columns", "company-enrichment", "enrich-column", "enrichment", "projects", "supabase", "table-action-items", "table-action-runs", "table-ingestion-runs", "tables"],
232
232
  // "link"/"join"/"connect"/"relate" route here for `tables link`. Added after a
233
233
  // blind user eval asked for exactly "link two tables" and was routed to
@@ -272,7 +272,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
272
272
  posture: "workspace_write",
273
273
  gatewayTools: ["oxygen_publishing_comments", "oxygen_publishing_analytics", "oxygen_posts_get", "oxygen_posts_create"],
274
274
  gatewayCommands: ["publishing comments list", "publishing analytics summary", "posts get", "posts create"],
275
- skills: ["oxygen-linkedin-marketing"],
275
+ skills: ["oxygen-publishing"],
276
276
  endpointSections: [],
277
277
  intentTerms: ["post artifact", "social post", "post comments", "public comments", "unanswered comments", "comments needing reply", "owned posts", "post reactions", "post performance", "posting analytics", "broadcast content"],
278
278
  },
@@ -286,7 +286,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
286
286
  posture: "workspace_write",
287
287
  gatewayTools: ["oxygen_signals_list", "oxygen_signals_leads_today", "oxygen_signals_registry", "oxygen_tags_attach"],
288
288
  gatewayCommands: ["signals list", "signals leads-today", "signals registry", "tags attach"],
289
- skills: ["oxygen-gtm", "oxygen-linkedin-marketing"],
289
+ skills: ["oxygen-signals"],
290
290
  endpointSections: ["signals", "signup-leads"],
291
291
  intentTerms: ["signal", "signals", "signal type", "tag signal", "intent", "website visit", "job change", "fundraising", "warm leads", "engager", "follower", "viewer"],
292
292
  },
@@ -325,7 +325,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
325
325
  posture: "paid_read",
326
326
  gatewayTools: ["oxygen_ads_searches", "oxygen_adyntel_ads_search", "oxygen_tools_search"],
327
327
  gatewayCommands: ["ads searches list", "ads searches save", "tools search"],
328
- skills: ["oxygen-gtm"],
328
+ skills: ["oxygen-signals"],
329
329
  endpointSections: ["ads"],
330
330
  intentTerms: ["ad", "ads", "advertising", "ad creative", "competitor ads", "ad intelligence", "meta ads", "linkedin ads", "saved ads search", "reusable ads search"],
331
331
  },
@@ -380,7 +380,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
380
380
  posture: "external_write",
381
381
  gatewayTools: ["oxygen_publishing_posts_create", "oxygen_publishing_posts_list", "oxygen_publishing_posts_approve", "oxygen_ugc_get"],
382
382
  gatewayCommands: ["publishing posts create", "publishing posts list", "publishing posts approve", "ugc programs list", "ugc programs duplicate", "ugc programs archive"],
383
- skills: ["oxygen-linkedin-marketing", "oxygen-ugc"],
383
+ skills: ["oxygen-publishing", "oxygen-ugc"],
384
384
  endpointSections: ["publishing", "ugc"],
385
385
  // "remove program" / "delete program" landed on route: null in a blind eval
386
386
  // (2026-09-10); a program is a UGC noun here, and its lifecycle verbs must
@@ -439,7 +439,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
439
439
  posture: "paid_read",
440
440
  gatewayTools: ["oxygen_companies_search_plan", "oxygen_people_search_plan", "oxygen_tools_search", "oxygen_find_company"],
441
441
  gatewayCommands: ["companies search plan", "people search plan", "tools search", "find company"],
442
- skills: ["oxygen-gtm"],
442
+ skills: ["oxygen-sourcing", "oxygen-enrichment"],
443
443
  endpointSections: ["companies", "find", "lead-sourcing", "people", "search", "sourcing", "tools", "verify"],
444
444
  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"],
445
445
  },
@@ -453,7 +453,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
453
453
  posture: "paid_read",
454
454
  gatewayTools: ["oxygen_tools_search", "oxygen_tools_get"],
455
455
  gatewayCommands: ["tools search", "tools get"],
456
- skills: ["oxygen-gtm", "oxygen-linkedin-marketing"],
456
+ skills: ["oxygen-signals"],
457
457
  endpointSections: [],
458
458
  intentTerms: ["public linkedin", "third party linkedin", "linkedin profile", "linkedin company", "linkedin post", "linkedin comments", "linkedin reactions", "linkedin engagers", "harvest", "cookieless"],
459
459
  negativeTerms: ["my linkedin", "our linkedin", "own linkedin", "connected account", "inbox", "send", "invite", "publish"],
@@ -468,7 +468,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
468
468
  posture: "mixed",
469
469
  gatewayTools: ["oxygen_integrations_list", "oxygen_integrations_connect", "oxygen_integrations_list_actions"],
470
470
  gatewayCommands: ["integrations list", "integrations connect", "custom-integrations list"],
471
- skills: ["oxygen-gtm"],
471
+ skills: ["oxygen-tables", "oxygen-gtm"],
472
472
  endpointSections: ["custom-integrations", "integrations"],
473
473
  intentTerms: ["integration", "integrations", "connect provider", "oauth", "byok", "bring your own key", "provider key", "provider connection", "custom http", "custom integration", "connection readiness"],
474
474
  },
@@ -521,7 +521,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
521
521
  posture: "mixed",
522
522
  gatewayTools: ["oxygen_linkedin_network_setup", "oxygen_linkedin_invitations_setup", "oxygen_senders_list", "oxygen_linkedin_connections_import", "oxygen_inbox_list"],
523
523
  gatewayCommands: ["linkedin network setup", "linkedin invitations setup", "senders list", "connections import", "inbox list"],
524
- skills: ["oxygen-linkedin-marketing", "oxygen-sequencer", "oxygen-unibox"],
524
+ skills: ["oxygen-signals", "oxygen-sequencer", "oxygen-unibox"],
525
525
  endpointSections: ["linkedin"],
526
526
  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", "working hours", "sending hours", "linkedin limits"],
527
527
  },
@@ -580,8 +580,8 @@ export function inferCapabilityRoute(query) {
580
580
  return {
581
581
  card: {
582
582
  ...recipes,
583
- execution: "Pick the matching audience Recipe (own, competitor, creator or keyword post engagers, company-page followers or own followers), resolve current company/ICP context, then read that Recipe and oxygen-recipes file guides/audience-campaign.md. Preserve the exact source, attribution, qualification rule/version and object IDs. Prepare a bounded native audience campaign for email, LinkedIn DM/connection requests or WhatsApp. Keep capture, standing qualification, router and Sequence disabled until their own approval and credit scope; the shared guide owns execution.",
584
- skills: ["oxygen-recipes", "oxygen-linkedin-marketing"],
583
+ execution: "Pick the matching audience Recipe (own, competitor, creator or keyword post engagers, company-page followers or own followers), resolve current company/ICP context, then read that Recipe and the oxygen-signals file references/audience-campaigns.md. Preserve the exact source, attribution, qualification rule/version and object IDs. Prepare a bounded native audience campaign for email, LinkedIn DM/connection requests or WhatsApp. Keep capture, standing qualification, router and Sequence disabled until their own approval and credit scope; the shared guide owns execution.",
584
+ skills: ["oxygen-recipes", "oxygen-signals"],
585
585
  },
586
586
  recommendedTools: ["oxygen_recipes_list", "oxygen_recipes_get", "oxygen_context_resolve"],
587
587
  recommendedCommands: ["recipes list", "recipes show", "context resolve"],
@@ -679,28 +679,46 @@ export function inferPrimitiveRoute(query) {
679
679
  return null;
680
680
  return route;
681
681
  }
682
+ /**
683
+ * Skills OXYGEN once served and has retired (2026-10-04: plays are inspiration
684
+ * read through the recipes API, never skills, and visual design is taught by
685
+ * its commands' own help; LinkedIn marketing split into the Publishing and
686
+ * Signals feature skills, with its account-safety rules in the sequencer's
687
+ * LinkedIn reference). The manifest no longer serves them, so nothing new
688
+ * can install, attach or advertise them; their audience below stays so an Agent
689
+ * version or Copilot session that already pinned one keeps its snapshot.
690
+ */
691
+ export const RETIRED_PRODUCT_SKILLS = Object.freeze(["oxygen-playbooks", "oxygen-visual-design", "oxygen-linkedin-marketing"]);
682
692
  /** Current hosted instruction audiences, shared by discovery and Agent snapshots. */
683
693
  export const OXYGEN_PRODUCT_SKILL_AUDIENCES = Object.freeze({
684
694
  "oxygen-onboarding": "workspace",
695
+ "oxygen-gtm-consultant": "workspace",
685
696
  "oxygen-gtm": "workspace",
697
+ "oxygen-tables": "workspace",
698
+ "oxygen-enrichment": "workspace",
699
+ "oxygen-sourcing": "workspace",
700
+ "oxygen-crm": "workspace",
686
701
  "oxygen-recipes": "workspace",
687
- "oxygen-playbooks": "workspace",
688
- "oxygen-linkedin-marketing": "workspace",
689
702
  "oxygen-agency-directory": "agency_partner",
690
703
  "oxygen-collaboration": "workspace",
691
704
  "oxygen-quickstart": "workspace",
692
705
  "oxygen-workflow-authoring": "workspace",
693
706
  "oxygen-knowledge": "workspace",
694
- "oxygen-visual-design": "workspace",
695
707
  "oxygen-unibox": "workspace",
696
708
  "oxygen-email-infra": "workspace",
697
709
  "oxygen-deliverability": "workspace",
698
710
  "oxygen-sequencer": "workspace",
711
+ "oxygen-publishing": "workspace",
712
+ "oxygen-signals": "workspace",
699
713
  "oxygen-table-tidy": "workspace",
700
714
  "oxygen-diagnostics": "workspace",
701
715
  "oxygen-feedback": "workspace",
702
716
  "oxygen-clay-migration": "workspace",
703
717
  "oxygen-ugc": "workspace",
718
+ // Retired (RETIRED_PRODUCT_SKILLS): readable for existing pins, never served.
719
+ "oxygen-playbooks": "workspace",
720
+ "oxygen-visual-design": "workspace",
721
+ "oxygen-linkedin-marketing": "workspace",
704
722
  });
705
723
  export function canReadProductSkillInstructions(name, entitlements) {
706
724
  if (entitlements.authenticated !== true)
@@ -747,9 +765,11 @@ export function readProductSkillInstructionResult(capabilityName, result) {
747
765
  }
748
766
  return null;
749
767
  }
750
- /** Public action cards never advertise invite-only or unknown skill instructions. */
768
+ /** Public action cards never advertise invite-only, retired or unknown skill instructions. */
751
769
  export function isPublicCapabilitySkill(name) {
752
- return Object.hasOwn(OXYGEN_PRODUCT_SKILL_AUDIENCES, name) && OXYGEN_PRODUCT_SKILL_AUDIENCES[name] === "workspace";
770
+ return Object.hasOwn(OXYGEN_PRODUCT_SKILL_AUDIENCES, name)
771
+ && OXYGEN_PRODUCT_SKILL_AUDIENCES[name] === "workspace"
772
+ && !RETIRED_PRODUCT_SKILLS.includes(name);
753
773
  }
754
774
  export function serializeCapabilityRoute(route, surfaceOrOptions = "cli", options = {}) {
755
775
  const surface = typeof surfaceOrOptions === "string" ? surfaceOrOptions : "cli";
@@ -930,7 +950,7 @@ const UGC_ROUTE_VIEW = {
930
950
  notFor: "Scheduling the workspace's own posts, one-to-one messaging, outreach campaigns, or keyword post search.",
931
951
  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.",
932
952
  posture: "mixed",
933
- skills: ["oxygen-ugc", "oxygen-linkedin-marketing"],
953
+ skills: ["oxygen-ugc", "oxygen-publishing"],
934
954
  };
935
955
  /** The card a route reports: Publishing's own, or its UGC view for a creator-program ask. */
936
956
  function routeCardFor(card, query) {
@@ -996,14 +1016,18 @@ function preferredSkillForIntent(card, query) {
996
1016
  if (/\b(stuck|failed|failure|trace|provenance|diagnos\w*)\b/.test(query))
997
1017
  return "oxygen-diagnostics";
998
1018
  if (isLinkedInProfileWatcherIntent(query))
999
- return "oxygen-linkedin-marketing";
1019
+ return "oxygen-signals";
1000
1020
  }
1021
+ // One person's email or phone is a one-shot lookup the enrichment skill
1022
+ // teaches (`find email`, `find phone`); list building stays with sourcing.
1023
+ if (card.id === "sourcing-and-provider-tools" && oneShotContactFindingRecommendation(query))
1024
+ return "oxygen-enrichment";
1001
1025
  if (card.id === "billing-and-budgets" && /\b(explain|why|reduce|increas\w*|burn|spent)\b/.test(query))
1002
1026
  return "oxygen-diagnostics";
1003
1027
  if (card.id === "sending-infrastructure" && /\b(bounc\w*|health|placement|deliverability|spam)\b/.test(query))
1004
1028
  return "oxygen-deliverability";
1005
1029
  if (["signals", "linkedin-public-research"].includes(card.id) && /\blinkedin\b/.test(query))
1006
- return "oxygen-linkedin-marketing";
1030
+ return "oxygen-signals";
1007
1031
  return null;
1008
1032
  }
1009
1033
  function isTableMaintenanceIntent(query) {
@@ -76,7 +76,7 @@ export const SENDER_LIMITS_SET_OPERATION = {
76
76
  contract: SENDER_LIMITS_PATCH_CONTRACT,
77
77
  cli: {
78
78
  path: ["senders", "limits", "set"],
79
- summary: "Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's sending schedule (sequences update --schedule-*, or --linkedin-* for LinkedIn alone) can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Caps saved before 25 Sep 2026 above 40 messages, 20 comments or 200 total actions were lowered to those levels once on 2 Oct 2026 (old values in sender metadata linkedin_limit_caution_2026_10); limits you save now are kept as set. Raising caps to answer a post's commenters: read `oxygen skills get oxygen-linkedin-marketing --file guides/safe-limits-doctrine.md` first. Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.",
79
+ summary: "Adjust per-account action limits and working hours. LinkedIn actions only run inside the account's working hours (default 07:00-22:00 every day, in the account's timezone); work due outside them is deferred to the next window, not failed. Set them with --timezone, --working-days, --working-hours-start and --working-hours-end; flags you omit keep their current value. A sequence's sending schedule (sequences update --schedule-*, or --linkedin-* for LinkedIn alone) can narrow the window for its LinkedIn steps, never widen it; email steps follow the sequence's email send window instead. Every limit flag shows its default and hard maximum; values above the maximum are clamped, and higher caps raise the risk of a LinkedIn restriction. Each action has its own cap (InMails per day and per rolling 30 days, profile views, profile lookups, endorsements, invitation withdrawals, posts), and every send except posts also counts toward --total-actions-per-day. Daily send caps run at half on Saturday and Sunday in the account's timezone unless --weekend-full-volume is set. A withdrawn invitation cannot be re-sent to the same person for 21 days. Own-feed posts and your own Unibox replies ignore working hours. A refused action names its reason (outside_working_hours defers it; linkedin_rate_limited names the limit and resets_at). Caps saved before 25 Sep 2026 above 40 messages, 20 comments or 200 total actions were lowered to those levels once on 2 Oct 2026 (old values in sender metadata linkedin_limit_caution_2026_10); limits you save now are kept as set. Raising caps to answer a post's commenters: read `oxygen skills get oxygen-sequencer --file references/linkedin.md` first. Changing limits is free. <id> accepts a sender account id, connection id, or Unipile account id.",
80
80
  argument: { name: "id", description: "Sender account id, connection id, or Unipile account id." },
81
81
  },
82
82
  mcp: {
@@ -1,5 +1,5 @@
1
1
  // GENERATED by apps/web/scripts/generate-copilot-journeys.ts from the recipe
2
- // catalog (`journey:` blocks in apps/web/src/agent-skills/oxygen-recipes/recipes).
2
+ // catalog (`journey:` blocks in apps/web/src/lib/recipes/plays).
3
3
  // Do not edit by hand — run `npm run skills:metadata -w @oxygen/web`.
4
4
  export const COPILOT_JOURNEYS_GENERATED = [
5
5
  {
@@ -1,25 +1,19 @@
1
1
  import { COPILOT_PLAYBOOKS_GENERATED } from "./copilot-playbooks.generated.js";
2
2
  export type CopilotPlaybookSlug = (typeof COPILOT_PLAYBOOKS_GENERATED)[number]["slug"];
3
3
  export type CopilotPlaybook = {
4
- /**
5
- * The skill slug the Copilot loads through its `skills` tool, and the
6
- * `name:` in the playbook file's frontmatter. The two are gated equal.
7
- */
4
+ /** The featured play's catalog slug: the play the Copilot reads before it works. */
8
5
  slug: CopilotPlaybookSlug;
9
6
  /** Customer-facing name, exactly as it is spoken in the composer prompt and shown on the Home chip. */
10
7
  title: string;
11
- /** The featured catalog recipe that carries this play: its kit, prerequisites and approval gates. */
12
- recipeSlug: string;
13
8
  /** The play's request in the person's own words, the same text its card copies (2026-10-03). */
14
9
  prompt: string;
15
10
  };
16
11
  export declare const COPILOT_PLAYBOOKS: readonly CopilotPlaybook[];
17
12
  /**
18
13
  * What a chip writes into the composer: the play's own request, the same words
19
- * its prompt card copies for the person's agent (plays as prompts, current
20
- * chat, 2026-10-03), then the skill named the way a person says it. The
21
- * Copilot's prompt maps that title back to the slug, so the user reads and
22
- * sends exactly what the chip promised.
14
+ * its prompt card copies for the person's agent (plays as prompts, 2026-10-03),
15
+ * then the play named the way a person says it. The Copilot's prompt maps that
16
+ * title back to the catalog slug, so the user reads and sends exactly what the
17
+ * chip promised.
23
18
  */
24
19
  export declare function copilotPlaybookPrompt(playbook: Pick<CopilotPlaybook, "title" | "prompt">): string;
25
- export declare function getCopilotPlaybook(slug: string): CopilotPlaybook | null;
@@ -1,21 +1,17 @@
1
1
  export declare const COPILOT_PLAYBOOKS_GENERATED: readonly [{
2
- readonly slug: "tam-sourcing";
2
+ readonly slug: "icp-to-account-map";
3
3
  readonly title: "TAM sourcing";
4
- readonly recipeSlug: "icp-to-account-map";
5
4
  readonly prompt: "Turn our ICP into a scored list of target accounts, with the evidence behind each score, so I can see which segments fit best.";
6
5
  }, {
7
- readonly slug: "linkedin-content-strategy";
6
+ readonly slug: "founder-posting-system";
8
7
  readonly title: "LinkedIn content strategy";
9
- readonly recipeSlug: "founder-posting-system";
10
8
  readonly prompt: "Help me post on LinkedIn consistently in my own voice: draft posts from our positioning and ICP, let me approve each one, and track how they perform.";
11
9
  }, {
12
10
  readonly slug: "inbound-led-outbound";
13
11
  readonly title: "Inbound-led outbound";
14
- readonly recipeSlug: "inbound-led-outbound";
15
12
  readonly prompt: "Turn the people who react to and comment on LinkedIn posts I pick into pipeline: score them against our ICP and message only the good fits from a capped sequence.";
16
13
  }, {
17
- readonly slug: "signal-based-outbound";
14
+ readonly slug: "daily-warm-lead-queue";
18
15
  readonly title: "Signal-based outbound";
19
- readonly recipeSlug: "daily-warm-lead-queue";
20
16
  readonly prompt: "Give me one ranked list every morning of the warmest people to contact, from website visitors, profile viewers, post engagers and new followers.";
21
17
  }];
@@ -1,30 +1,26 @@
1
- // GENERATED by apps/web/scripts/generate-copilot-playbooks.ts from the recipe
2
- // catalog (featured recipes: `featured_order`, `label`, `playbook`, `prompt` in
3
- // apps/web/src/agent-skills/oxygen-recipes/recipes).
1
+ // GENERATED by apps/web/scripts/generate-copilot-playbooks.ts from the play
2
+ // catalog (featured plays: `featured_order`, `label`, `prompt` in
3
+ // apps/web/src/lib/recipes/plays).
4
4
  // Do not edit by hand — run `npm run skills:metadata -w @oxygen/web`.
5
5
  export const COPILOT_PLAYBOOKS_GENERATED = [
6
6
  {
7
- slug: "tam-sourcing",
7
+ slug: "icp-to-account-map",
8
8
  title: "TAM sourcing",
9
- recipeSlug: "icp-to-account-map",
10
9
  prompt: "Turn our ICP into a scored list of target accounts, with the evidence behind each score, so I can see which segments fit best.",
11
10
  },
12
11
  {
13
- slug: "linkedin-content-strategy",
12
+ slug: "founder-posting-system",
14
13
  title: "LinkedIn content strategy",
15
- recipeSlug: "founder-posting-system",
16
14
  prompt: "Help me post on LinkedIn consistently in my own voice: draft posts from our positioning and ICP, let me approve each one, and track how they perform.",
17
15
  },
18
16
  {
19
17
  slug: "inbound-led-outbound",
20
18
  title: "Inbound-led outbound",
21
- recipeSlug: "inbound-led-outbound",
22
19
  prompt: "Turn the people who react to and comment on LinkedIn posts I pick into pipeline: score them against our ICP and message only the good fits from a capped sequence.",
23
20
  },
24
21
  {
25
- slug: "signal-based-outbound",
22
+ slug: "daily-warm-lead-queue",
26
23
  title: "Signal-based outbound",
27
- recipeSlug: "daily-warm-lead-queue",
28
24
  prompt: "Give me one ranked list every morning of the warmest people to contact, from website visitors, profile viewers, post engagers and new followers.",
29
25
  },
30
26
  ];
@@ -2,14 +2,11 @@ import { COPILOT_PLAYBOOKS_GENERATED } from "./copilot-playbooks.generated.js";
2
2
  export const COPILOT_PLAYBOOKS = COPILOT_PLAYBOOKS_GENERATED;
3
3
  /**
4
4
  * What a chip writes into the composer: the play's own request, the same words
5
- * its prompt card copies for the person's agent (plays as prompts, current
6
- * chat, 2026-10-03), then the skill named the way a person says it. The
7
- * Copilot's prompt maps that title back to the slug, so the user reads and
8
- * sends exactly what the chip promised.
5
+ * its prompt card copies for the person's agent (plays as prompts, 2026-10-03),
6
+ * then the play named the way a person says it. The Copilot's prompt maps that
7
+ * title back to the catalog slug, so the user reads and sends exactly what the
8
+ * chip promised.
9
9
  */
10
10
  export function copilotPlaybookPrompt(playbook) {
11
- return `${playbook.prompt} Use the ${playbook.title} skill.`;
12
- }
13
- export function getCopilotPlaybook(slug) {
14
- return COPILOT_PLAYBOOKS.find((playbook) => playbook.slug === slug) ?? null;
11
+ return `${playbook.prompt} Use the ${playbook.title} play.`;
15
12
  }
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1011.650";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1011.679";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1011.650";
2
+ export const OXYGEN_BUILD_VERSION = "1.1011.679";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1011.650",
3
+ "version": "1.1011.679",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",