@oxygen-agent/cli 1.1003.12 → 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.
- package/README.md +1 -1
- package/dist/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- package/package.json +2 -1
|
@@ -21,8 +21,8 @@ export const COPILOT_SKILL_SNAPSHOTS_GENERATED = [
|
|
|
21
21
|
slug: "linkedin-content-strategy",
|
|
22
22
|
title: "Playbook: LinkedIn content strategy",
|
|
23
23
|
sources: ["oxygen-playbooks/playbooks/linkedin-content-strategy.md"],
|
|
24
|
-
sha256: "
|
|
25
|
-
content: "---\nname: linkedin-content-strategy\ndescription: \"Install and run the founder LinkedIn content engine: a wiki-grounded strategy page, an idea backlog, a week of drafts approved post by post, scheduled publishing, answered comments, weekly engagement read-back, outlier mining, and the hand-off of engagers to inbound-led outbound.\"\n---\n\n# Playbook: LinkedIn content strategy\n\n## Motion in one sentence\n\nA strategy page written from the wiki fixes the pillars, cadence and voice; ideas accumulate in a backlog; a week of drafts is generated from that wiki and approved one post at a time; the scheduler publishes each from the founder's own account; comments are answered through a previewed reply; engagement is read back weekly; outlier mining says which structures travelled; the lessons file back into the wiki; and the engagers become the inbound signal. Each stage reads the page the last one wrote — a posting habit without the strategy page is a treadmill, and an outlier bank without the habit is a swipe file nobody drains.\n\nRead first: `oxygen recipes show founder-posting-system --json`, `oxygen recipes show weekly-content-calendar --json`, `oxygen recipes show content-outlier-mining --json` (the stage 7 kit). Mechanics: `oxygen skills get oxygen-linkedin-marketing --json`.\n\n## Stage table\n\n| # | Stage | Owner | Install / command | Inputs | Cap | Approval | Verify | Stop |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| 0 | Context + strategy | Knowledge Graph | `oxygen knowledge resolve --purpose outbound_copy --require-ready --json` → `oxygen knowledge page upsert --slug content-strategy --type strategy --title \"Content strategy\" --status active --body \"<pillars / cadence / voice>\" --json` | `positioning`, `icp`, `offers`, `voice`, all `active` | 3–5 pillars, each naming one ICP problem | none — a `strategy` page is working knowledge, logged as a revision | `oxygen knowledge page get content-strategy --json` | a stub ICP page, or a pillar with no buyer problem |\n| 1 | Sender | Publishing | `oxygen senders list --status active --json`; `oxygen senders health <sender_id> --json` (`oxygen senders limits get` for the budgets) | one connected LinkedIn account | own-feed ceiling 25 posts / 24h | none — reads | `active`, no open checkpoint | `restricted` / `credentials_required`: reconnect, never route around it |\n| 2 | Backlog | Publishing | `oxygen publishing ideas add --text \"<angle>\" --topic <pillar> --json`; `oxygen publishing ideas list --json` | angles as they occur; structures from stage 7 | two weeks of slots ahead | none (internal write) | `oxygen publishing ideas list --limit 50 --json` | an empty backlog on drafting day: mine outliers, don't draft from taste |\n| 3 | Draft + queue | Publishing | `oxygen publishing posts draft --template personal_story --topic \"<angle>\" --max-credits <cap> --json` → `oxygen publishing drafts edit <draft_id> --text-file mon.txt --json` → `oxygen publishing drafts accept <draft_id> --publish-at <iso+offset> --sender <sender_id> --json` | a pillar + a backlog angle per slot | 3–5 posts/week; 1–5 variants (default 3) | `draft-week`: paid, cap from the preview; queueing needs none — it never sends | `oxygen publishing posts list --approval-status needs_approval --json` | a bare `--publish-at`: with no offset it is stored as UTC |\n| 4 | Review + publish | Publishing | `oxygen publishing posts review <post_id> --max-credits <cap> --json`; `oxygen publishing mentions resolve --text-file mon.txt --json` → `oxygen publishing posts approve <post_id> --json` | the exact final text, read by the founder | ≤15% promotion posts; ≤1 lead-magnet CTA/week | `review-post`: paid, advisory. `post-publish`: human, every post, never batched | `oxygen publishing posts get <post_id> --json` — attempts, provider id, deep-link | an unresolved `@<public-identifier>`; `linkedin_rate_limited` |\n| 5 | Comments | Publishing | `oxygen publishing comments list --view unanswered --json` → `oxygen publishing comments reply <comment_id> --text-file reply.txt --json` → `oxygen publishing comments approve <action_id> --content-hash <sha256> --approved --json` → `oxygen publishing comments resolve <comment_id> --json` | the unanswered queue (30-day scope) | one reply per comment, inside 48h | `comment-reply`: human approves that exact previewed text and its hash | the queue drains; handled items read `resolved` | a comment older than 48h with no reply |\n| 6 | Read back | Publishing + Posts | `oxygen publishing analytics summary --channel linkedin --range 30d --json`; `oxygen publishing analytics post <post_id> --json`; `oxygen posts get --post <post_url> --json` | published posts | metrics refresh ~30 days after publish | none — reads | reactions, comments, reshares present; impressions honestly null | every metric zero after two days: confirm `published`, not `deferred` |\n| 7 | Mine outliers | Workflows — kit stage `linkedin-keyword-outliers` | `oxygen recipes apply content-outlier-mining --dry-run --json` → without `--dry-run`; then `oxygen blueprints apply linkedin-creator-outliers --table-ref content_outliers=<bank_table_id> --json` | 3–4 buyer-language keywords; ≤25 public `/in/` URLs | 7-day lookback; 1 page/keyword on the pilot; ceiling from preflight | `pilot-live` then `sweep-arm`: human, re-granted on reapply | `oxygen workflows tail <run_id> --json`; `oxygen tables query <bank_table_id> --json` | everything near par, or top rows are hiring posts |\n| 8 | Learn | Knowledge Graph | `oxygen knowledge synthesize --kind voice --json` → `--approved`; `oxygen knowledge log append --event decision --slug content-strategy --json` | analytics, comments, the bank, draft edits | one rule per loop | canonical voice / positioning file a proposal: `oxygen knowledge proposals approve <id> --json` | `oxygen knowledge page get content-strategy --json` | a change with no receipt behind it |\n| 9 | Hand off | Signals → inbound-led outbound | `oxygen engagement harvest --post <social_id> --source unipile --recurring --json`; `oxygen engagement status --post <social_id> --json` | the composite `social_id` from `oxygen posts get` | free; drips against the ingest budget | none — it contacts nobody | `oxygen engagement engagers --post <social_id> --json` previews the table | outreach begins here: run the inbound-led-outbound playbook |\n\n## Numeric guardrails\n\n- **Cadence and mix:** 3–5 posts/week planned; 2–3/week for four consecutive weeks is the floor before any signal is readable. At most 15% promotion posts and one lead-magnet CTA post per week. Consistency leads, reach lags.\n- **Own-feed ceiling 25 posts / 24h** per account, a platform default: own-feed publishing sits outside the outreach send quotas because it is not aimed at another member, but still refuses a sender that is not `active`. Own-post reads draw the other-API-read budget (200/day default, 1,000 max); `engagement harvest` draws the separate ingest budget (20/day, 200 max), so a big post drips over days. `oxygen engagement engagers` walks both sources up to `--max-pages` (default 5, max 20) × 100.\n- **Paid calls:** 1–5 variants per draft call (default 3), one AI call per draft and per review. Take every ceiling from the preview and `oxygen tools get <tool_id> --json`, never from prose; sweep ceilings from `oxygen blueprints preflight`.\n- **Comment SLA 48 hours**, over a rolling 30-day owned-post scope: an older thread is absent from the queue, not silent. Analytics refresh for ~30 days; reactions, comments and reshares are real, impressions and saves are never exposed for a member's own posts.\n- **Outlier scoring:** `rank_score = 100 × √(outlier_multiple × audience_index)`, **100 is par**; a healthy sweep puts ~5–15% above 200. Defaults: `lookback_days` 7, `min_post_age_hours` 24, `min_author_followers` 1000, weights 1 / 3 / 5; 3–4 keywords (max 8), 10–25 creators (max 25).\n\n## Angle gate spec\n\nThe gate is not who gets contacted — nobody is contacted here — it is **which idea becomes a public post in the founder's name**. An item advances to draft only when all of these hold: it maps to a named pillar on `[[content-strategy]]`; it carries one concrete claim, story or artifact traceable to a wiki page, a shipped thing or a real customer outcome; it names the audience in plain words; if promotional, the week's share is still under 15%; and its voice matches the pinned voice page. The first three are enforced at drafting, because the draft call grounds on the wiki by default; voice by the free channel lint plus the paid `publishing posts review` check.\n\nNothing bypasses it, because **approval is per post and is not batchable server-side**: a draft accepted from the AI queue, a post created directly, and a row loaded by `oxygen publishing import` all land needs-approval. Borrowing from the bank has its own rule — lift the **structure**, never the text, and only from a row that cleared `rank_score` 200 with a `baseline_kind` you trust; `author_unresolved` and `maturing` rows are unscored and are not evidence.\n\n## Approval gates, mapped to who decides\n\n| Gate | What it authorises | Copilot posture |\n| --- | --- | --- |\n| kit apply (`oxygen recipes apply content-outlier-mining`) | 0-credit install of the sweep, workflow disabled | human card, once |\n| `draft-week` / `review-post` | one paid AI drafting or voice/claims call | human sets each cap from the preview |\n| `post-publish` | one public post in the founder's name, on this exact text | human, every post, never batched |\n| `comment-reply` | one public reply bound to the preview's `action_id` and `content_hash` | human approves that exact text |\n| `pilot-live` then `sweep-arm` | one live outlier cycle, then the recurring sweep | human for each; re-grant on reapply (revision-bound) |\n| amplification (`oxygen publishing amplification create`, then `enable`) | real public engagement from teammates' accounts, spending credits | human twice: grant, then arm |\n| canonical wiki edits | voice / brand / positioning / pinned playbooks | a proposal a human approves |\n\nInstallation is never permission: the kit applies at 0 credits with its workflow disabled, and every paid run, publish, reply and armed sweep is its own gate above.\n\nReads need no approval (`oxygen publishing posts list`, `oxygen publishing analytics summary`, `oxygen senders health`, `oxygen tables query`); working wiki pages write as logged revisions.\n\n## Failure modes\n\n- **The two axes.** `--status` is pipeline position (`draft` → `scheduled` → `queued` → `published`); `--approval-status` is whether a human said yes. A queued post is both `scheduled` and `needs_approval`. Filter on the approval axis or you will report an empty queue you just filled.\n- **A bare local `--publish-at`.** With no offset it is stored as UTC; `--timezone` only changes the display. Pass an offset or `Z`.\n- **The activity URN is not the `social_id`.** `posts reactions`, `posts comments`, `engagement engagers` and `engagement harvest` all need the composite `social_id` from `oxygen posts get`. Nothing lists your own feed, so record the id when the post goes out.\n- **Mentions and content.** A verified `@<public-identifier>` goes in the post text — a structured `mentions` array is ignored and `content.mentions` rejected; approve blocks anything unresolved. `--content-json` replaces the whole content object, so a media update drops an existing `first_comment` unless you resend it.\n- **A post on a dead sender never dispatches**; fix the account instead of re-queueing. A rate limit **defers** rather than drops and names `resets_at`, so retrying in a loop only burns quota. Company-page posting is a tested-negative anti-pattern — post from the personal profile.\n- **Generic drafts are a thin wiki, not a thin prompt**; a bank where everything scores near par is a keyword naming a category. Fix the page or the phrase, never the prompt length or the weights.\n- **Outreach leaking in.** The moment the plan is to message an engager, this motion is over — hand off at stage 9. Sequences own initiation; a CRM person is created at a warm-lead gate, never because someone reacted.\n\n## Data-quality checks (after every week)\n\n1. `oxygen publishing posts list --status published --json` — every planned slot published, or one sat `needs_approval` all week? A missed slot is the failure this motion exists to prevent.\n2. `oxygen publishing comments list --view unanswered --json` — nothing older than 48h, and never `resolved` on a thread still owed an answer.\n3. `oxygen publishing analytics summary --channel linkedin --range 30d --json` — all-zero after two days means `deferred`. Then read the comments, not the counts: ICP titles, or peers and recruiters?\n4. `oxygen tables query <bank_table_id> --limit 25 --json` — mostly `author_unresolved` or `maturing` is a cold cache or short lookback; a backlog under two weeks deep means next week starts from taste.\n\n## Learning loop\n\n- **What auto-files:** a draft edit snapshots the AI's original copy, so an accepted edit records what the founder changed; `oxygen publishing drafts reject <draft_id> --reason \"...\" --json` files the reason; revisions, review findings, the metric series and the comment queue are durable.\n- **What to synthesise weekly:** which pillar produced ICP-fit comments rather than peer likes; which post *shape* travelled (a number in line one, a named enemy, a before/after); and `oxygen knowledge synthesize --kind voice --json`, a voice guide distilled from real sent copy.\n- **What to change, one rule per loop:** the pillar mix, a slot's time, a keyword on the source table (pause it, never delete it), the CTA, or the voice page. Write the change and its reason into the wiki; a voice or positioning change files a proposal.\n- **Define \"good\" first:** an inbound conversation from an ICP-fit person — a DM, a comment thread that becomes a call, a reply naming their own version of the problem. Not reach, not reactions, not a bank with more rows.\n\n## Go-live checklist\n\n1. `oxygen senders list --status active --json`, then `oxygen senders health <sender_id> --json` — one healthy account, no open checkpoint.\n2. `oxygen knowledge resolve --purpose outbound_copy --require-ready --json`, then `oxygen knowledge page upsert --slug content-strategy --type strategy --status active --body \"...\" --json`.\n3. `oxygen publishing ideas add --text \"...\" --topic <pillar> --json` until two weeks of slots exist, then draft and queue each slot (stage 3).\n4. `oxygen publishing posts list --approval-status needs_approval --json`, present the week, then `oxygen publishing posts approve <post_id> --json` one post at a time.\n5. `oxygen publishing comments list --view unanswered --json` — the 48h loop runs and a named person owns it.\n6. `oxygen recipes apply content-outlier-mining --dry-run --json`, apply, one `oxygen workflows call <workflow_id> --mode dry-run --json`, one `pilot-live` cycle, then `oxygen workflows enable <workflow_id> --approved --max-credits <cap> --json`.\n7. `oxygen budget list --json` for the org backstop, then `oxygen knowledge log append --event note --slug content-strategy --json`.\n\n## Open questions (state them, do not resolve them)\n\n- LinkedIn does not expose impressions, saves or sends for a member's own posts, so reach is unmeasurable and earned-media value stays null. Never substitute a proxy and call it reach.\n- The cadence and mix numbers are operator estimates, not measured benchmarks. Replace them with four weeks of your own evidence.\n- The outlier kit is `beta`, and `oxygen recipes apply content-outlier-mining` installs only the keyword sweep; the creator watch applies separately against the same tables with `--table-ref`, and omitting those flags reports a table collision — the guard working.\n- Whether engagers flow automatically into outreach is not this playbook's call; stage 9 hands them over deliberately. Amplification from teammates' accounts is likewise a founder decision about the company's public name.\n\n## Resume point\n\n`oxygen recipes show founder-posting-system --json` and `oxygen knowledge page get content-strategy --json`, then `oxygen publishing posts list --json`, `oxygen publishing comments list --json` and `oxygen workflows list --json`. Start from what exists, never from scratch.\n",
|
|
24
|
+
sha256: "3a0e90ad7451e433473ce09b2db86ea3864eaab1719f19be6333a33fd38be710",
|
|
25
|
+
content: "---\nname: linkedin-content-strategy\ndescription: \"Install and run the founder LinkedIn content engine: a wiki-grounded strategy page, an idea backlog, a week of drafts approved post by post, scheduled publishing, answered comments, weekly engagement read-back, outlier mining, and the hand-off of engagers to inbound-led outbound.\"\n---\n\n# Playbook: LinkedIn content strategy\n\n## Motion in one sentence\n\nA strategy page written from the wiki fixes the pillars, cadence and voice; ideas accumulate in a backlog; a week of drafts is generated from that wiki and approved one post at a time; the scheduler publishes each from the founder's own account; comments are answered through a previewed reply; engagement is read back weekly; outlier mining says which structures travelled; the lessons file back into the wiki; and the engagers become the inbound signal. Each stage reads the page the last one wrote — a posting habit without the strategy page is a treadmill, and an outlier bank without the habit is a swipe file nobody drains.\n\nRead first: `oxygen recipes show founder-posting-system --json`, `oxygen recipes show weekly-content-calendar --json`, `oxygen recipes show content-outlier-mining --json` (the stage 7 kit). Mechanics: `oxygen skills get oxygen-linkedin-marketing --json`.\n\n## Stage table\n\n| # | Stage | Owner | Install / command | Inputs | Cap | Approval | Verify | Stop |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| 0 | Context + strategy | Knowledge Graph | `oxygen knowledge resolve --purpose outbound_copy --require-ready --json` → `oxygen knowledge page upsert --slug content-strategy --type strategy --title \"Content strategy\" --status active --body \"<pillars / cadence / voice>\" --json` | `positioning`, `icp`, `offers`, `voice`, all `active` | 3–5 pillars, each naming one ICP problem | none — a `strategy` page is working knowledge, logged as a revision | `oxygen knowledge page get content-strategy --json` | a stub ICP page, or a pillar with no buyer problem |\n| 1 | Sender | Publishing | `oxygen senders list --status active --json`; `oxygen senders health <sender_id> --json` (`oxygen senders limits get` for the budgets) | one connected LinkedIn account | own-feed ceiling 25 posts / 24h | none — reads | `active`, no open checkpoint | `restricted` / `credentials_required`: reconnect, never route around it |\n| 2 | Backlog | Publishing | `oxygen publishing ideas add --text \"<angle>\" --topic <pillar> --json`; `oxygen publishing ideas list --json` | angles as they occur; structures from stage 7 | two weeks of slots ahead | none (internal write) | `oxygen publishing ideas list --limit 50 --json` | an empty backlog on drafting day: mine outliers, don't draft from taste |\n| 3 | Draft + queue | Publishing | `oxygen publishing posts draft --template personal_story --topic \"<angle>\" --max-credits <cap> --json` → `oxygen publishing drafts edit <draft_id> --text-file mon.txt --json` → `oxygen publishing drafts accept <draft_id> --publish-at <iso+offset> --sender <sender_id> --json` | a pillar + a backlog angle per slot | 3–5 posts/week; 1–5 variants (default 3) | `draft-week`: paid, cap from the preview; queueing needs none — it never sends | `oxygen publishing posts list --approval-status needs_approval --json` | a bare `--publish-at`: with no offset it is stored as UTC |\n| 4 | Review + publish | Publishing | `oxygen publishing posts review <post_id> --max-credits <cap> --json`; `oxygen publishing mentions resolve --text-file mon.txt --json` → `oxygen publishing posts approve <post_id> --json` | the exact final text, read by the founder | ≤15% promotion posts; ≤1 lead-magnet CTA/week | `review-post`: paid, advisory. `post-publish`: human, every post, never batched | `oxygen publishing posts get <post_id> --json` — attempts, provider id, deep-link | an unresolved `@<public-identifier>`; `linkedin_rate_limited` |\n| 5 | Comments | Publishing | `oxygen publishing comments list --view unanswered --json` → `oxygen publishing comments reply <comment_id> --text-file reply.txt --json` → `oxygen publishing comments approve <action_id> --content-hash <sha256> --approved --json` → `oxygen publishing comments resolve <comment_id> --json` | the unanswered queue (30-day scope) | one reply per comment, inside 48h | `comment-reply`: human approves that exact previewed text and its hash | the queue drains; handled items read `resolved` | a comment older than 48h with no reply |\n| 6 | Read back | Publishing + Posts | `oxygen publishing analytics summary --channel linkedin --range 30d --json` (winners, `leader` lead, `null_reasons`); `oxygen publishing analytics timeseries --channel linkedin --range 30d --json` (earned per day, growing or shrinking vs the previous period, follower change); `oxygen publishing analytics post <post_id> --json`; `oxygen posts get --post <post_url> --json` | published posts | metrics refresh ~30 days after publish | none — reads | reactions, comments, reshares and — on your own original posts — impressions present; reposts counted in `reposts_excluded`, not in totals | every metric zero after two days: confirm `published`, not `deferred` |\n| 7 | Mine outliers | Workflows — kit stage `linkedin-keyword-outliers` | `oxygen recipes apply content-outlier-mining --dry-run --json` → without `--dry-run`; then `oxygen blueprints apply linkedin-creator-outliers --table-ref content_outliers=<bank_table_id> --json` | 3–4 buyer-language keywords; ≤25 public `/in/` URLs | 7-day lookback; 1 page/keyword on the pilot; ceiling from preflight | `pilot-live` then `sweep-arm`: human, re-granted on reapply | `oxygen workflows tail <run_id> --json`; `oxygen tables query <bank_table_id> --json` | everything near par, or top rows are hiring posts |\n| 8 | Learn | Knowledge Graph | `oxygen knowledge synthesize --kind voice --json` → `--approved`; `oxygen knowledge log append --event decision --slug content-strategy --json` | analytics, comments, the bank, draft edits | one rule per loop | canonical voice / positioning file a proposal: `oxygen knowledge proposals approve <id> --json` | `oxygen knowledge page get content-strategy --json` | a change with no receipt behind it |\n| 9 | Hand off | Signals → inbound-led outbound | `oxygen engagement harvest --post <social_id> --source unipile --recurring --json`; `oxygen engagement status --post <social_id> --json` | the composite `social_id` from `oxygen posts get` | free; drips against the ingest budget | none — it contacts nobody | `oxygen engagement engagers --post <social_id> --json` previews the table | outreach begins here: run the inbound-led-outbound playbook |\n\n## Numeric guardrails\n\n- **Cadence and mix:** 3–5 posts/week planned; 2–3/week for four consecutive weeks is the floor before any signal is readable. At most 15% promotion posts and one lead-magnet CTA post per week. Consistency leads, reach lags.\n- **Own-feed ceiling 25 posts / 24h** per account, a platform default: own-feed publishing sits outside the outreach send quotas because it is not aimed at another member, but still refuses a sender that is not `active`. Own-post reads draw the other-API-read budget (200/day default, 1,000 max); `engagement harvest` draws the separate ingest budget (20/day, 200 max), so a big post drips over days. `oxygen engagement engagers` walks both sources up to `--max-pages` (default 5, max 20) × 100.\n- **Paid calls:** 1–5 variants per draft call (default 3), one AI call per draft and per review. Take every ceiling from the preview and `oxygen tools get <tool_id> --json`, never from prose; sweep ceilings from `oxygen blueprints preflight`.\n- **Comment SLA 48 hours**, over a rolling 30-day owned-post scope: an older thread is absent from the queue, not silent. Analytics refresh for ~30 days; reactions, comments and reshares are real; impressions come from LinkedIn's own post analytics, which it shows only to the author, so they are real for the connected member's original posts; saves and sends are not exposed.\n- **Outlier scoring:** `rank_score = 100 × √(outlier_multiple × audience_index)`, **100 is par**; a healthy sweep puts ~5–15% above 200. Defaults: `lookback_days` 7, `min_post_age_hours` 24, `min_author_followers` 1000, weights 1 / 3 / 5; 3–4 keywords (max 8), 10–25 creators (max 25).\n\n## Angle gate spec\n\nThe gate is not who gets contacted — nobody is contacted here — it is **which idea becomes a public post in the founder's name**. An item advances to draft only when all of these hold: it maps to a named pillar on `[[content-strategy]]`; it carries one concrete claim, story or artifact traceable to a wiki page, a shipped thing or a real customer outcome; it names the audience in plain words; if promotional, the week's share is still under 15%; and its voice matches the pinned voice page. The first three are enforced at drafting, because the draft call grounds on the wiki by default; voice by the free channel lint plus the paid `publishing posts review` check.\n\nNothing bypasses it, because **approval is per post and is not batchable server-side**: a draft accepted from the AI queue, a post created directly, and a row loaded by `oxygen publishing import` all land needs-approval. Borrowing from the bank has its own rule — lift the **structure**, never the text, and only from a row that cleared `rank_score` 200 with a `baseline_kind` you trust; `author_unresolved` and `maturing` rows are unscored and are not evidence.\n\n## Approval gates, mapped to who decides\n\n| Gate | What it authorises | Copilot posture |\n| --- | --- | --- |\n| kit apply (`oxygen recipes apply content-outlier-mining`) | 0-credit install of the sweep, workflow disabled | human card, once |\n| `draft-week` / `review-post` | one paid AI drafting or voice/claims call | human sets each cap from the preview |\n| `post-publish` | one public post in the founder's name, on this exact text | human, every post, never batched |\n| `comment-reply` | one public reply bound to the preview's `action_id` and `content_hash` | human approves that exact text |\n| `pilot-live` then `sweep-arm` | one live outlier cycle, then the recurring sweep | human for each; re-grant on reapply (revision-bound) |\n| amplification (`oxygen publishing amplification create`, then `enable`) | real public engagement from teammates' accounts, spending credits; LinkedIn may restrict those accounts | human twice: grant, then arm, each with `--acknowledge-risk` |\n| canonical wiki edits | voice / brand / positioning / pinned playbooks | a proposal a human approves |\n\nInstallation is never permission: the kit applies at 0 credits with its workflow disabled, and every paid run, publish, reply and armed sweep is its own gate above.\n\nReads need no approval (`oxygen publishing posts list`, `oxygen publishing analytics summary`, `oxygen senders health`, `oxygen tables query`); working wiki pages write as logged revisions.\n\n## Failure modes\n\n- **The two axes.** `--status` is pipeline position (`draft` → `scheduled` → `queued` → `published`); `--approval-status` is whether a human said yes. A queued post is both `scheduled` and `needs_approval`. Filter on the approval axis or you will report an empty queue you just filled.\n- **A bare local `--publish-at`.** With no offset it is stored as UTC; `--timezone` only changes the display. Pass an offset or `Z`.\n- **The activity URN is not the `social_id`.** `posts reactions`, `posts comments`, `engagement engagers` and `engagement harvest` all need the composite `social_id` from `oxygen posts get`. Nothing lists your own feed, so record the id when the post goes out.\n- **Mentions and content.** A verified `@<public-identifier>` goes in the post text — a structured `mentions` array is ignored and `content.mentions` rejected; approve blocks anything unresolved. `--content-json` replaces the whole content object, so a media update drops an existing `first_comment` unless you resend it.\n- **A post on a dead sender never dispatches**; fix the account instead of re-queueing. A rate limit **defers** rather than drops and names `resets_at`, so retrying in a loop only burns quota. Company-page posting is a tested-negative anti-pattern — post from the personal profile.\n- **Generic drafts are a thin wiki, not a thin prompt**; a bank where everything scores near par is a keyword naming a category. Fix the page or the phrase, never the prompt length or the weights.\n- **Outreach leaking in.** The moment the plan is to message an engager, this motion is over — hand off at stage 9. Sequences own initiation; a CRM person is created at a warm-lead gate, never because someone reacted.\n\n## Data-quality checks (after every week)\n\n1. `oxygen publishing posts list --status published --json` — every planned slot published, or one sat `needs_approval` all week? A missed slot is the failure this motion exists to prevent.\n2. `oxygen publishing comments list --view unanswered --json` — nothing older than 48h, and never `resolved` on a thread still owed an answer.\n3. `oxygen publishing analytics summary --channel linkedin --range 30d --json` — all-zero after two days means `deferred`. Then read the comments, not the counts: ICP titles, or peers and recruiters?\n4. `oxygen tables query <bank_table_id> --limit 25 --json` — mostly `author_unresolved` or `maturing` is a cold cache or short lookback; a backlog under two weeks deep means next week starts from taste.\n\n## Learning loop\n\n- **What auto-files:** a draft edit snapshots the AI's original copy, so an accepted edit records what the founder changed; `oxygen publishing drafts reject <draft_id> --reason \"...\" --json` files the reason; revisions, review findings, the metric series and the comment queue are durable.\n- **What to synthesise weekly:** which pillar produced ICP-fit comments rather than peer likes; which post *shape* travelled (a number in line one, a named enemy, a before/after); and `oxygen knowledge synthesize --kind voice --json`, a voice guide distilled from real sent copy.\n- **What to change, one rule per loop:** the pillar mix, a slot's time, a keyword on the source table (pause it, never delete it), the CTA, or the voice page. Write the change and its reason into the wiki; a voice or positioning change files a proposal.\n- **Define \"good\" first:** an inbound conversation from an ICP-fit person — a DM, a comment thread that becomes a call, a reply naming their own version of the problem. Not reach, not reactions, not a bank with more rows.\n\n## Go-live checklist\n\n1. `oxygen senders list --status active --json`, then `oxygen senders health <sender_id> --json` — one healthy account, no open checkpoint.\n2. `oxygen knowledge resolve --purpose outbound_copy --require-ready --json`, then `oxygen knowledge page upsert --slug content-strategy --type strategy --status active --body \"...\" --json`.\n3. `oxygen publishing ideas add --text \"...\" --topic <pillar> --json` until two weeks of slots exist, then draft and queue each slot (stage 3).\n4. `oxygen publishing posts list --approval-status needs_approval --json`, present the week, then `oxygen publishing posts approve <post_id> --json` one post at a time.\n5. `oxygen publishing comments list --view unanswered --json` — the 48h loop runs and a named person owns it.\n6. `oxygen recipes apply content-outlier-mining --dry-run --json`, apply, one `oxygen workflows call <workflow_id> --mode dry-run --json`, one `pilot-live` cycle, then `oxygen workflows enable <workflow_id> --approved --max-credits <cap> --json`.\n7. `oxygen budget list --json` for the org backstop, then `oxygen knowledge log append --event note --slug content-strategy --json`.\n\n## Open questions (state them, do not resolve them)\n\n- LinkedIn shows impressions only to a post's author and never exposes saves or sends. Reach is measurable for the connected member's original posts (and earned-media value once a CPM is set with `oxygen publishing analytics emv`); for anything else it stays empty. Never substitute a proxy and call it reach.\n- The cadence and mix numbers are operator estimates, not measured benchmarks. Replace them with four weeks of your own evidence.\n- The outlier kit is `beta`, and `oxygen recipes apply content-outlier-mining` installs only the keyword sweep; the creator watch applies separately against the same tables with `--table-ref`, and omitting those flags reports a table collision — the guard working.\n- Whether engagers flow automatically into outreach is not this playbook's call; stage 9 hands them over deliberately. Amplification from teammates' accounts is likewise a founder decision about the company's public name.\n\n## Resume point\n\n`oxygen recipes show founder-posting-system --json` and `oxygen knowledge page get content-strategy --json`, then `oxygen publishing posts list --json`, `oxygen publishing comments list --json` and `oxygen workflows list --json`. Start from what exists, never from scratch.\n",
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
slug: "inbound-led-outbound",
|
|
@@ -35,7 +35,7 @@ export const COPILOT_SKILL_SNAPSHOTS_GENERATED = [
|
|
|
35
35
|
slug: "signal-based-outbound",
|
|
36
36
|
title: "Playbook: Signal-based outbound",
|
|
37
37
|
sources: ["oxygen-playbooks/playbooks/signal-based-outbound.md"],
|
|
38
|
-
sha256: "
|
|
39
|
-
content: "---\nname: signal-based-outbound\ndescription: \"Installs the general buying-signal engine: arm company-level pulse sources and person-level engagement sources, expand a hit account into named buyers, gate, rank and score them against the ICP, and route only the qualified band into a capped sequence.\"\n---\n\n# Playbook: signal-based outbound\n\n## Motion in one sentence\n\nSources are armed against one segment in two lanes — **pulse** (a company acts: hiring surge, funding, tech install, news) and **engagement** (a person acts: website visit, profile view, post reaction, follow). The pulse lane expands a hit account into named buyers, the engagement lane resolves a person directly, both converge at the warm-lead gate where a CRM person is created, then rank into one daily queue, are scored against the ICP and split — the strongest to the founder's own hand, the qualified band into a capped sequence. The order matters because every step narrows: capture is wide, expansion is account-bound, resolution is identity-bound, ranking is intent-bound, the band gate is fit-bound, and only the last step contacts anyone.\n\nRecipes cover the engagement sources one at a time (`oxygen recipes show website-visitor-outreach --json`, `profile-viewer-outreach`, `linkedin-intent-to-signup`, `daily-warm-lead-queue`, `speed-to-lead`); the pulse lane has none and is stages 1–2. Every command here takes `--json`.\n\n## Stage table\n\n| # | Stage | Owner | Install / command | Inputs | Cap | Approval | Verify | Stop |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| 0 | Context + registry | Knowledge Graph + Signals | `oxygen context resolve --purpose outbound_copy`; `oxygen knowledge page get icp`; `oxygen signals registry`; `oxygen signals list --since 7` | a filled `icp`; a written segment | — | none | `icp` reads `active`, not a stub; your types read `captureStatus: live` | a stub ICP, or a type you assumed exists that the registry lacks |\n| 1 | **Pulse — company signals** | Signals — signal search | `oxygen signals search plan --prompt \"<goal>\" --family hiring --scope market --last-days 30 --estimate` → `oxygen signals search run --plan-json ./plan.json --mode dry_run` → same `--mode live --approved --max-credits <cap>` | `--family` hiring/tech/funding/acquisition/news/job_change; `--scope market`, or `watch_list --domains` | the plan's own `estimated_credits`; `--max-pages` defaults to 1; a standing harvest adds `--bind-feed --every daily@9 --max-credits-per-cycle <cap>` | `pulse-harvest`: paid, human, per run; `--bind-feed` is a **second**, standing grant | the plan's `routes[]`, `filter_application`, `schedulable`; then `oxygen feeds deliveries --table <signals_table>` | `no_default_provider_chain`; `feed_not_incremental`; `basis` not `provider_count` |\n| 2 | **Accounts → buyers** | Tables | `oxygen people search plan --prompt \"<persona>\" --company-domains <csv> --max-per-company 3 --estimate` → `oxygen people search run --plan-json ./plan.json --mode dry_run` → same `--mode live --approved --max-credits <cap> --table <people_table>` | hit domains from `oxygen tables query <signals_table>`; persona titles and seniorities | `--max-per-company` ≤3; only domains whose event is inside the window | `persona-expansion`: paid, human, per run | the plan's `estimated_match_count.basis`; `oxygen tables query <people_table> --limit 25` | an account with no plausible persona — drop it, never loosen `--titles` |\n| 3 | Engagement capture | Signals — pull feeds + reveal webhook | `oxygen linkedin intent setup --account <sender_id> --post <social_id>`; `oxygen viewers import --account <sender_id>`; `oxygen followers import --account <sender_id>`; a reveal source POSTs the webhook, hand-proved by `oxygen signals record --event website_visit --external-event-id <id>` | a healthy sender; the composite `social_id`, not the URN; a de-anon source | the account's ingest budget; ~15 relations reads/day; ≤20 pages × 100 on `oxygen engagement engagers --post <social_id>` | `arm-capture`: human, once per sender — free, contacts nobody | `oxygen linkedin intent status`; `oxygen feeds list`; `oxygen viewers status --account <sender_id>` | feeds `paused`/`error`; sender bad in `oxygen senders health <sender_id>` |\n| 4 | **Warm-lead gate** | Records | `oxygen crm setup --live` once; per engagement type `oxygen blueprints apply crm-post-engager` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap>`; pulse buyers via `oxygen crm assert` | `crm-website-visitor`, `crm-profile-viewer`, `crm-post-engager`, `crm-follower` — only where the source delivers | free per event under a positive per-delivery cap | `arm-router`: human per router; re-grant after every reapply | `oxygen workflows runs --workflow <workflow_id> --limit 10`; `oxygen crm activity timeline people <row_id>` | a router armed over a dark source — it only makes capture look live |\n| 5 | Rank | Signals | `oxygen signals leads-today --limit 25 --within-days 7`; narrative via `oxygen blueprints apply daily-gtm-digest --input-json '{...}'` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap>` | the CRM people table as `source_tables` | `--within-days` ≤30, `--limit` ≤100; digest cap per cycle | `enable-digest`: human, standing spend | leads carry a type and an occurred-at | an empty queue with armed sources is a capture problem |\n| 6 | Assess | Tables | `oxygen columns add <people_table> --kind ai --key icp_fit --data-type jsonb --definition-json '{...}'` → `oxygen columns run <people_table> icp_fit --limit 10 --dry-run` → same `--all --background --approved --max-credits <cap>` → `oxygen tables auto-run set <people_table> --columns icp_fit --max-credits <cap>` | the `icp` page; identified rows only | pilot ≤10 rows; the auto-run cap is per batch, rows past it skip with `credit_limit_reached` | `run-assessment`: paid, human; the auto-run is scoped standing permission | `oxygen cells inspect <people_table> <row_id> icp_fit`, one per band | generic reasons: fix the ICP page, not the prompt |\n| 7 | Route (band gate) | Sequences | own-network `oxygen linkedin intent autoenroll --account <sender_id> --sequence <slug> --kinds profile_viewers,followers` (previews) → same `--approved --max-enrolls-per-day <n>`; table-backed `oxygen sequences enroll <slug> --leads-file leads.json --exclude-contacted` | only rows past the band gate | at or below the sender's effective daily cap; one attempt per person | `arm-routing`: human — the grant reaches people captured later | the preview's audience split; `oxygen sequences enrollments <slug>` | `--include-existing-network` proposed unasked; most leads `bound_to_other_sender` |\n| 8 | Send | Sequences | `oxygen sequences create --name \"Signal-based outbound\" --slug signal-based-outbound --steps-file steps.json --table <people_table> --url-column linkedin_url --max-new-enrollments-per-day 10 --exclude-contacted` → `oxygen sequences update signal-based-outbound --senders <sender_id>` → `oxygen sequences start signal-based-outbound` → same `--approved --max-live-sends <n>` | copy naming the observable event, never an inferred motive | a new sender is floored at 5 invites and 5 messages a day for its first three days | `create-sequence`: human, every call | `oxygen sequences analytics --sequence signal-based-outbound --range 30d` | sender `restricted` or paused; reply-stop fired |\n| 9 | Replies → CRM | Records | `oxygen crm automation set crm-lead-stage-router --armed --live --approved --max-credits 1` | — | free per event | none beyond arming | `oxygen crm automation rules`; `oxygen crm pipeline` | a reply that moved no stage — a disarmed router never re-arms itself |\n| 10 | Learn | Knowledge Graph | `oxygen knowledge synthesize --kind campaign_learnings --sequence <sequence_id>` → same `--approved`; `oxygen knowledge log append --event decision --slug campaign-learnings --summary \"...\"` | receipts, delivery ledger, analytics | weekly; one rule | canonical pages route through the proposal queue | `oxygen knowledge lint` | a claim with no receipt behind it |\n\n## Numeric guardrails\n\n- **Weights are product data, not a workspace knob.** `oxygen signals registry --json` returns each type's `sourceWeight` — today `website_visit` 100 > `profile_view` 60 > `post_reaction` 40 > `new_follower` 20, all `engagement` family, all live. Never hardcode one, and never claim one for a type the registry lacks: the pulse families have none.\n- **Pulse:** `--max-pages` defaults to 1; the only honest prices are the plan's `estimated_credits` range and `oxygen tools get <tool_id> --json`. Quote a match count only when `estimated_match_count.basis` is `provider_count`. `--last-days` is the event window: a six-month-old hiring post is history, not intent.\n- **Expansion:** `--max-per-company` ≤3, `--require-email` where the lane sends email, and only accounts whose event is in the window. One account never becomes a list.\n- **Queue:** `signals leads-today` defaults to 7 days (`--within-days`, max 30) and 25 leads (`--limit`, max 100). Work the top 5–10 a morning.\n- **Bands:** strong 80–100 = the founder's own message, never automated; qualified 50–79 = the capped sequence; weak and out ≤49 = keep the evidence, send nothing; unknown = hold. Fit is company/segment fit, size/stage and person/persona fit; strength and recency set *order*, never *fit*.\n- **Ceilings and sending:** a trigger, feed or auto-run armed without an explicit `--max-credits` inherits the plan tier's per-delivery default (the preview prints which). `--max-new-enrollments-per-day` bounds first touches, the grant's `--max-enrolls-per-day` defaults to the sender's warm-up-ramped cap, and the 5-invite / 5-message floor for a new sender's first three days is not bypassable.\n- **Identity before scoring:** a row with neither an email nor a canonical `linkedin.com/in/` URL cannot pass the warm-lead gate and is not worth assessing. Missing evidence is never positive evidence.\n\n## Gate spec\n\n**The warm-lead gate (does a person exist).** A raw signal never creates a CRM person. In the engagement lane the armed `crm-*` router for that exact type does, from a stable identity — email plus domain for `website_visit` and signup, a canonical LinkedIn URL for the rest. Two consequences: the engagement→signal bridge **attaches only to people who already have a CRM record** and never creates one, so a capture armed without its router produces rows nobody can rank (writers before signals); and pulse rows carry no registered type, so no router fires on them. In the pulse lane the gate is stage 2 plus an explicit `oxygen crm assert --json` — a hit account becomes a person only once a named buyer with a verified identity is resolved against it. A company event is never itself a lead, installation is never permission, and a signal is a prioritization input: never consent, never proof of intent.\n\n**The band gate (who gets contacted).** The assessment column returns `{ score, band, segment, persona, confidence, reason, disqualifiers, missing_evidence }`. Enforce it where enrollment happens, never in a display formula — row queries cannot filter on formula columns, so filter on the stored JSON plus a null routing status and re-check in the routing step. A person advances only when the band is `qualified`, the score sits inside it, confidence is high or medium, the segment is recognised, `disqualifiers` is empty, person and employer evidence are complete, the identity is canonical, `do_not_contact` is not true, and no prior routing status exists. Nothing bypasses it: never `oxygen sequences enroll <slug> --from-table --json` against a raw signal or expansion table, and never treat `oxygen signals leads-today --json` as an enrollment source — it ranks by strength then recency and does **not** score fit.\n\n## Approval gates, mapped to who decides\n\n| Gate | What it authorises | Attended Copilot | Unattended run |\n| --- | --- | --- | --- |\n| kit apply (`oxygen recipes apply <slug> --dry-run`, then without it) | 0-credit install; workflows land disabled | auto-approvable write | n/a |\n| `pulse-harvest` | one priced company-signal harvest into a table | human card | its `--max-credits` |\n| `bind-feed` | the recurring harvest — standing, separate from the run | human card; re-asked when `--every` or the cap changes | its `--max-credits-per-cycle` |\n| `persona-expansion` | one priced people search scoped to the hit domains | human card | its `--max-credits` |\n| `arm-capture` | the standing read of the account's own viewers, followers, connections, posts | human card, per sender | never armed unattended |\n| `arm-router` | a `crm-*` router's free internal writes | auto-approvable write | its per-delivery cap |\n| `enable-digest` | the daily digest's recurring synthesis | human card | its per-cycle cap |\n| `run-assessment` | one paid column run, then the standing auto-run | human card; the auto-run is scoped standing permission | its per-batch cap |\n| `arm-routing` | enrolling captured people, including people captured later | human card — it reaches strangers | its `--max-enrolls-per-day` |\n| `create-sequence` | creating, attaching a sender to, and starting the sequence | human card, every call — `external_write` is never waived | its `--max-live-sends` |\n| canonical wiki edits | voice, brand, positioning | a proposal a human approves | never |\n\nReads need no approval: `recipes show`, `signals registry|list|leads-today`, `signals search plan`, `people search plan`, `feeds list|deliveries`, `tables query`, `cells inspect`, `workflows runs`, `senders health`, and every preview or `--dry-run`.\n\n## Failure modes\n\n- **Pulse rows are a table, not the stream.** The registry holds four engagement types and no pulse type, so a hiring / funding / tech / news harvest sourced natively with `oxygen signals search run` lands as table rows that no router subscribes to and that never reach `signals leads-today`; work them from the table (stages 1–2), which is shipped sourcing, not a workaround. Check: harvest, then `oxygen signals list --json` — unchanged. Correction: expand, score and enroll from the table (stages 2–7); never promise a trigger on them.\n- **Treating a company event as a lead.** A funding round is an account fact; nobody there raised a hand. Correction: stage 2 first, one persona at a time, with copy naming the event rather than an invented intent.\n- **`no_default_provider_chain` is an answer.** Market-wide `job_change` refuses by design — use `--scope watch_list --domains <csv>` or name a provider, never a substitute family. Likewise market-wide funding and acquisition are one-shot: read `schedulable` before offering a cadence, or the bind returns `feed_not_incremental`.\n- **Quoting a count nobody published.** Render a number only for `basis: \"provider_count\"` and name the probe tool.\n- **Expansion that fills a quota.** Loosening `--titles` or raising `--max-per-company` until the count looks healthy buys a list, not a signal lane. Drop the account instead.\n- **The bridge attaches; it never creates.** A viewer or engager with no CRM record produces no signal — capture rows accrue while `oxygen signals list --json` stays flat. Correction: arm that type's `crm-*` router first. The mirror failure is a router armed over a dark source: free, silent, and it only makes capture look live — compare `oxygen workflows runs --workflow <workflow_id> --limit 10 --json` with the source's own status read.\n- **Suppression is keyed on LinkedIn provider ids**, so email-only reveals surface unchecked — check do-not-contact, competitors, customers and open deals by hand before the first send on a website or pulse lane.\n- **Double-counted redeliveries.** `oxygen signals record --json` is idempotent on `--external-event-id`; a relay that omits it re-counts every retry.\n- **Revision-bound authority.** Reapplying a blueprint publishes a new **disabled** revision; the standing approval must be granted again.\n- **Ceilings, not bugs.** Named viewers are a fraction of real views and de-anonymization resolves a minority of traffic; re-arming widens neither.\n\n## Data-quality checks (after every cycle)\n\n1. `oxygen signals list --since 7 --limit 100 --json` — events by type. A type at zero whose source is armed is a dark source: inaccessible, never absence.\n2. `oxygen feeds list --json`, then `oxygen feeds deliveries --table <table> --json` — any feed `error` or `exhausted`, any `rejected` delivery.\n3. Pulse table: every row has a `signal_date` inside the window and an openable `source_url`; stale dates are history and must not be expanded.\n4. `oxygen tables query <people_table> --limit 100 --json` — rows with neither an email nor a canonical `linkedin.com/in/` URL are held, never given an invented identity. Dedupe keys are `provider_id` (people), `event_key` (touchpoints) and the plan `upsert_key` (pulse).\n5. `oxygen budget list --json` and `oxygen limits show --json` — the resolved thresholds still match what you armed.\n\n## Learning loop\n\n- **What auto-files:** the table delivery ledger, workflow run receipts, enrollment receipts, sequence analytics, CRM stage changes and reply classifications.\n- **What to synthesise weekly:** positive-reply rate *by signal type and by lane* (a funding hit and a profile view are not one channel), by source and by band; band precision; time from event to first touch. `oxygen knowledge synthesize --kind campaign_learnings --sequence <sequence_id> --json`, read it, then re-run `--approved`.\n- **One rule per loop:** the band threshold, the segment, the pulse `--last-days` window, `--max-per-company`, one source on or off, or one cap — written with `oxygen knowledge log append --event decision --slug campaign-learnings --summary \"...\" --json`; a canonical change files a proposal.\n- **What you cannot change:** a type's `sourceWeight`. There is no per-workspace weighting surface, and pulse families carry none, so their ordering is yours to define in the queue you work.\n- Define \"good\" before arming: a positive reply, a booked meeting, or a closed deal. A loop with no written definition of good is automation, not a loop.\n\n## Go-live checklist\n\n1. `oxygen signals registry --json` + `oxygen signals list --json` — the live types, weights, baseline; `oxygen knowledge page get icp --json` reads `active`, with the segment written beside it.\n2. `oxygen senders health <sender_id> --json` — one usable sender, its warm-up day and daily caps; `oxygen crm setup --live --json` once.\n3. Pulse: `oxygen signals search plan --json` read in full (routes, dropped filters, `schedulable`), then dry-run, then the approved live run under a cap.\n4. Expansion: `oxygen people search plan --company-domains <csv> --max-per-company 3 --estimate --json`, dry-run, then the approved live run into the people table.\n5. Engagement: arm only what you have (`oxygen linkedin intent setup --account <sender_id> --json`, `oxygen viewers import --account <sender_id> --json`, `oxygen followers import --account <sender_id> --json`, the reveal webhook), each verified by its own status read.\n6. Per armed type: `oxygen blueprints apply crm-profile-viewer --json` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap> --json`, confirmed by `oxygen crm activity timeline people <row_id> --json`.\n7. `oxygen signals leads-today --limit 10 --json` returns people carrying a type; `oxygen columns run <people_table> icp_fit --limit 10 --dry-run --json`, then the approved run, then one assessment read per band.\n8. `oxygen sequences start signal-based-outbound --json` previewed, then `--approved --max-live-sends <n>`; `oxygen linkedin intent autoenroll --account <sender_id> --sequence signal-based-outbound --json` previewed — read the audience split — then `--approved` with explicit caps.\n9. `oxygen crm automation rules --json` shows `crm-lead-stage-router` armed; `oxygen budget list --json` and `oxygen limits show --json` match what you armed; log it with `oxygen knowledge log append --event note --slug campaign-learnings --summary \"...\" --json`.\n\n## Open questions (state them, do not resolve them)\n\n- Pulse and product stream emission is unfinished: the registry carries no hiring / funding / tech / acquisition / news / product-usage type, so those harvests are table data nothing subscribes to. Stages 1–2 source and expand pulse hits natively and work them from the table; the stream/registry half is the unfinished end state.\n- Because pulse families carry no `sourceWeight`, the queue cannot interleave a funding hit with a profile view. Cross-lane ranking is a human judgement today; never present it as one ordered list the product produced.\n- `signals registry` is a read-only snapshot derived from code constants — no registry or subscription objects exist, so \"a workflow triggers on a signal\" means a blueprint bound to one type.\n- The suppression identity gap (LinkedIn provider ids only) leaves email-only reveals unchecked, and no `post_comment` type exists, so a comment ranks as a reaction.\n- No CLI command mints a self-serve inbound URL for a de-anonymization vendor, and no `/signals` web route renders the stream or the queue — both are parity gaps to record, not journeys to describe.\n- \"Arm every source at once\" is not ratified strategy. Start with one lane, one source and one bounded segment with a named downstream owner.\n\n## Resume point\n\n`oxygen signals registry --json` — which types exist, which are live, and where a pulse family's absence from the stream becomes visible. Then `oxygen signals list --json` (arriving), `oxygen feeds list --json` (armed), `oxygen tables list --json` (signal and expansion tables), `oxygen crm automation rules --json` plus `oxygen workflows list --json` (resolving and routing), and `oxygen sequences list --json` (sending). Start there, never from scratch.\n",
|
|
38
|
+
sha256: "2e7aeef6d3a2eaee0820d273feb60a0fa8a466a4adee4e009a5737534c64bf9b",
|
|
39
|
+
content: "---\nname: signal-based-outbound\ndescription: \"Installs the general buying-signal engine: arm company-level pulse sources and person-level engagement sources, expand a hit account into named buyers, gate, rank and score them against the ICP, and route only the qualified band into a capped sequence.\"\n---\n\n# Playbook: signal-based outbound\n\n## Motion in one sentence\n\nSources are armed against one segment in two lanes — **pulse** (a company acts: hiring surge, funding, tech install, news) and **engagement** (a person acts: website visit, profile view, post reaction, follow). The pulse lane expands a hit account into named buyers, the engagement lane resolves a person directly, both converge at the warm-lead gate where a CRM person is created, then rank into one daily queue, are scored against the ICP and split — the strongest to the founder's own hand, the qualified band into a capped sequence. The order matters because every step narrows: capture is wide, expansion is account-bound, resolution is identity-bound, ranking is intent-bound, the band gate is fit-bound, and only the last step contacts anyone.\n\nRecipes cover the engagement sources one at a time (`oxygen recipes show website-visitor-outreach --json`, `profile-viewer-outreach`, `linkedin-intent-to-signup`, `daily-warm-lead-queue`, `speed-to-lead`); the pulse lane has none and is stages 1–2. Every command here takes `--json`.\n\n## Stage table\n\n| # | Stage | Owner | Install / command | Inputs | Cap | Approval | Verify | Stop |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n| 0 | Context + registry | Knowledge Graph + Signals | `oxygen context resolve --purpose outbound_copy`; `oxygen knowledge page get icp`; `oxygen signals registry`; `oxygen signals list --since 7` | a filled `icp`; a written segment | — | none | `icp` reads `active`, not a stub; your types read `captureStatus: live` | a stub ICP, or a type you assumed exists that the registry lacks |\n| 1 | **Pulse — company signals** | Signals — signal search | `oxygen signals search plan --prompt \"<goal>\" --family hiring --scope market --last-days 30 --estimate` → `oxygen signals search run --plan-json ./plan.json --mode dry_run` → same `--mode live --approved --max-credits <cap>` | `--family` hiring/tech/funding/acquisition/news/job_change; `--scope market`, or `watch_list --domains` | the plan's own `estimated_credits`; `--max-pages` defaults to 1; a standing harvest adds `--bind-feed --every daily@9 --max-credits-per-cycle <cap>` | `pulse-harvest`: paid, human, per run; `--bind-feed` is a **second**, standing grant | the plan's `routes[]`, `filter_application`, `schedulable`; then `oxygen feeds deliveries --table <signals_table>` | `no_default_provider_chain`; `feed_not_incremental`; `basis` not `provider_count` |\n| 2 | **Accounts → buyers** | Tables | `oxygen people search plan --prompt \"<persona>\" --company-domains <csv> --max-per-company 3 --estimate` → `oxygen people search run --plan-json ./plan.json --mode dry_run` → same `--mode live --approved --max-credits <cap> --table <people_table>` | hit domains from `oxygen tables query <signals_table>`; persona titles and seniorities | `--max-per-company` ≤3; only domains whose event is inside the window | `persona-expansion`: paid, human, per run | the plan's `estimated_match_count.basis`; `oxygen tables query <people_table> --limit 25` | an account with no plausible persona — drop it, never loosen `--titles` |\n| 3 | Engagement capture | Signals — pull feeds + reveal webhook | `oxygen linkedin intent setup --account <sender_id> --post <social_id>`; `oxygen viewers import --account <sender_id>`; `oxygen followers import --account <sender_id>`; a reveal source POSTs the webhook, hand-proved by `oxygen signals record --event website_visit --external-event-id <id>` | a healthy sender; the composite `social_id`, not the URN; a de-anon source | the account's ingest budget; ~15 relations reads/day; ≤20 pages × 100 on `oxygen engagement engagers --post <social_id>` | `arm-capture`: human, once per sender — free, contacts nobody | `oxygen linkedin intent status`; `oxygen feeds list`; `oxygen viewers status --account <sender_id>` (reads both lanes; `stalled` names `oxygen feeds resume <id>`) | feeds `paused`/`error`; `viewers status` `stalled`/`unbound`; sender bad in `oxygen senders health <sender_id>` |\n| 4 | **Warm-lead gate** | Records | `oxygen crm setup --live` once; per engagement type `oxygen blueprints apply crm-post-engager` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap>`; pulse buyers via `oxygen crm assert` | `crm-website-visitor`, `crm-profile-viewer`, `crm-post-engager`, `crm-follower` — only where the source delivers | free per event under a positive per-delivery cap | `arm-router`: human per router; re-grant after every reapply | `oxygen workflows runs --workflow <workflow_id> --limit 10`; `oxygen crm activity timeline people <row_id>` | a router armed over a dark source — it only makes capture look live |\n| 5 | Rank | Signals | `oxygen signals leads-today --limit 25 --within-days 7`; narrative via `oxygen blueprints apply daily-gtm-digest --input-json '{...}'` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap>` | the CRM people table as `source_tables` | `--within-days` ≤30, `--limit` ≤100; digest cap per cycle | `enable-digest`: human, standing spend | leads carry a type and an occurred-at | an empty queue with armed sources is a capture problem |\n| 6 | Assess | Tables | `oxygen columns add <people_table> --kind ai --key icp_fit --data-type jsonb --definition-json '{...}'` → `oxygen columns run <people_table> icp_fit --limit 10 --dry-run` → same `--all --background --approved --max-credits <cap>` → `oxygen tables auto-run set <people_table> --columns icp_fit --max-credits <cap>` | the `icp` page; identified rows only | pilot ≤10 rows; the auto-run cap is per batch, rows past it skip with `credit_limit_reached` | `run-assessment`: paid, human; the auto-run is scoped standing permission | `oxygen cells inspect <people_table> <row_id> icp_fit`, one per band | generic reasons: fix the ICP page, not the prompt |\n| 7 | Route (band gate) | Sequences | own-network `oxygen linkedin intent autoenroll --account <sender_id> --sequence <slug> --kinds profile_viewers,followers` (previews) → same `--approved --max-enrolls-per-day <n>`; table-backed `oxygen sequences enroll <slug> --leads-file leads.json --exclude-contacted` | only rows past the band gate | at or below the sender's effective daily cap; one attempt per person | `arm-routing`: human — the grant reaches people captured later | the preview's audience split; `oxygen sequences enrollments <slug>` | `--include-existing-network` proposed unasked; most leads `bound_to_other_sender` |\n| 8 | Send | Sequences | `oxygen sequences create --name \"Signal-based outbound\" --slug signal-based-outbound --steps-file steps.json --table <people_table> --url-column linkedin_url --max-new-enrollments-per-day 10 --exclude-contacted` → `oxygen sequences update signal-based-outbound --senders <sender_id>` → `oxygen sequences start signal-based-outbound` → same `--approved --max-live-sends <n>` | copy naming the observable event, never an inferred motive | a new sender is floored at 5 invites and 5 messages a day for its first three days | `create-sequence`: human, every call | `oxygen sequences analytics --sequence signal-based-outbound --range 30d` | sender `restricted` or paused; reply-stop fired |\n| 9 | Replies → CRM | Records | `oxygen crm automation set crm-lead-stage-router --armed --live --approved --max-credits 1` | — | free per event | none beyond arming | `oxygen crm automation rules`; `oxygen crm pipeline` | a reply that moved no stage — a disarmed router never re-arms itself |\n| 10 | Learn | Knowledge Graph | `oxygen knowledge synthesize --kind campaign_learnings --sequence <sequence_id>` → same `--approved`; `oxygen knowledge log append --event decision --slug campaign-learnings --summary \"...\"` | receipts, delivery ledger, analytics | weekly; one rule | canonical pages route through the proposal queue | `oxygen knowledge lint` | a claim with no receipt behind it |\n\n## Numeric guardrails\n\n- **Weights are product data, not a workspace knob.** `oxygen signals registry --json` returns each type's `sourceWeight` — today `website_visit` 100 > `profile_view` 60 > `post_reaction` 40 > `new_follower` 20, all `engagement` family, all live. Never hardcode one, and never claim one for a type the registry lacks: the pulse families have none.\n- **Pulse:** `--max-pages` defaults to 1; the only honest prices are the plan's `estimated_credits` range and `oxygen tools get <tool_id> --json`. Quote a match count only when `estimated_match_count.basis` is `provider_count`. `--last-days` is the event window: a six-month-old hiring post is history, not intent.\n- **Expansion:** `--max-per-company` ≤3, `--require-email` where the lane sends email, and only accounts whose event is in the window. One account never becomes a list.\n- **Queue:** `signals leads-today` defaults to 7 days (`--within-days`, max 30) and 25 leads (`--limit`, max 100). Work the top 5–10 a morning.\n- **Bands:** strong 80–100 = the founder's own message, never automated; qualified 50–79 = the capped sequence; weak and out ≤49 = keep the evidence, send nothing; unknown = hold. Fit is company/segment fit, size/stage and person/persona fit; strength and recency set *order*, never *fit*.\n- **Ceilings and sending:** a trigger, feed or auto-run armed without an explicit `--max-credits` inherits the plan tier's per-delivery default (the preview prints which). `--max-new-enrollments-per-day` bounds first touches, the grant's `--max-enrolls-per-day` defaults to the sender's warm-up-ramped cap, and the 5-invite / 5-message floor for a new sender's first three days is not bypassable.\n- **Identity before scoring:** a row with neither an email nor a canonical `linkedin.com/in/` URL cannot pass the warm-lead gate and is not worth assessing. Missing evidence is never positive evidence.\n\n## Gate spec\n\n**The warm-lead gate (does a person exist).** A raw signal never creates a CRM person. In the engagement lane the armed `crm-*` router for that exact type does, from a stable identity — email plus domain for `website_visit` and signup, a canonical LinkedIn URL for the rest. Two consequences: the engagement→signal bridge **attaches only to people who already have a CRM record** and never creates one, so a capture armed without its router produces rows nobody can rank (writers before signals); and pulse rows carry no registered type, so no router fires on them. In the pulse lane the gate is stage 2 plus an explicit `oxygen crm assert --json` — a hit account becomes a person only once a named buyer with a verified identity is resolved against it. A company event is never itself a lead, installation is never permission, and a signal is a prioritization input: never consent, never proof of intent.\n\n**The band gate (who gets contacted).** The assessment column returns `{ score, band, segment, persona, confidence, reason, disqualifiers, missing_evidence }`. Enforce it where enrollment happens, never in a display formula — row queries cannot filter on formula columns, so filter on the stored JSON plus a null routing status and re-check in the routing step. A person advances only when the band is `qualified`, the score sits inside it, confidence is high or medium, the segment is recognised, `disqualifiers` is empty, person and employer evidence are complete, the identity is canonical, `do_not_contact` is not true, and no prior routing status exists. Nothing bypasses it: never `oxygen sequences enroll <slug> --from-table --json` against a raw signal or expansion table, and never treat `oxygen signals leads-today --json` as an enrollment source — it ranks by strength then recency and does **not** score fit.\n\n## Approval gates, mapped to who decides\n\n| Gate | What it authorises | Attended Copilot | Unattended run |\n| --- | --- | --- | --- |\n| kit apply (`oxygen recipes apply <slug> --dry-run`, then without it) | 0-credit install; workflows land disabled | auto-approvable write | n/a |\n| `pulse-harvest` | one priced company-signal harvest into a table | human card | its `--max-credits` |\n| `bind-feed` | the recurring harvest — standing, separate from the run | human card; re-asked when `--every` or the cap changes | its `--max-credits-per-cycle` |\n| `persona-expansion` | one priced people search scoped to the hit domains | human card | its `--max-credits` |\n| `arm-capture` | the standing read of the account's own viewers, followers, connections, posts | human card, per sender | never armed unattended |\n| `arm-router` | a `crm-*` router's free internal writes | auto-approvable write | its per-delivery cap |\n| `enable-digest` | the daily digest's recurring synthesis | human card | its per-cycle cap |\n| `run-assessment` | one paid column run, then the standing auto-run | human card; the auto-run is scoped standing permission | its per-batch cap |\n| `arm-routing` | enrolling captured people, including people captured later | human card — it reaches strangers | its `--max-enrolls-per-day` |\n| `create-sequence` | creating, attaching a sender to, and starting the sequence | human card, every call — `external_write` is never waived | its `--max-live-sends` |\n| canonical wiki edits | voice, brand, positioning | a proposal a human approves | never |\n\nReads need no approval: `recipes show`, `signals registry|list|leads-today`, `signals search plan`, `people search plan`, `feeds list|deliveries`, `tables query`, `cells inspect`, `workflows runs`, `senders health`, and every preview or `--dry-run`.\n\n## Failure modes\n\n- **Pulse rows are a table, not the stream.** The registry holds four engagement types and no pulse type, so a hiring / funding / tech / news harvest sourced natively with `oxygen signals search run` lands as table rows that no router subscribes to and that never reach `signals leads-today`; work them from the table (stages 1–2), which is shipped sourcing, not a workaround. Check: harvest, then `oxygen signals list --json` — unchanged. Correction: expand, score and enroll from the table (stages 2–7); never promise a trigger on them.\n- **Treating a company event as a lead.** A funding round is an account fact; nobody there raised a hand. Correction: stage 2 first, one persona at a time, with copy naming the event rather than an invented intent.\n- **`no_default_provider_chain` is an answer.** Market-wide `job_change` refuses by design — use `--scope watch_list --domains <csv>` or name a provider, never a substitute family. Likewise market-wide funding and acquisition are one-shot: read `schedulable` before offering a cadence, or the bind returns `feed_not_incremental`.\n- **Quoting a count nobody published.** Render a number only for `basis: \"provider_count\"` and name the probe tool.\n- **Expansion that fills a quota.** Loosening `--titles` or raising `--max-per-company` until the count looks healthy buys a list, not a signal lane. Drop the account instead.\n- **The bridge attaches; it never creates.** A viewer or engager with no CRM record produces no signal — capture rows accrue while `oxygen signals list --json` stays flat. Correction: arm that type's `crm-*` router first. The mirror failure is a router armed over a dark source: free, silent, and it only makes capture look live — compare `oxygen workflows runs --workflow <workflow_id> --limit 10 --json` with the source's own status read.\n- **Suppression is keyed on LinkedIn provider ids**, so email-only reveals surface unchecked — check do-not-contact, competitors, customers and open deals by hand before the first send on a website or pulse lane.\n- **Double-counted redeliveries.** `oxygen signals record --json` is idempotent on `--external-event-id`; a relay that omits it re-counts every retry.\n- **Revision-bound authority.** Reapplying a blueprint publishes a new **disabled** revision; the standing approval must be granted again.\n- **Ceilings, not bugs.** Named viewers are a fraction of real views and de-anonymization resolves a minority of traffic; re-arming widens neither.\n\n## Data-quality checks (after every cycle)\n\n1. `oxygen signals list --since 7 --limit 100 --json` — events by type. A type at zero whose source is armed is a dark source: inaccessible, never absence.\n2. `oxygen feeds list --json`, then `oxygen feeds deliveries --table <table> --json` — any feed `error` or `exhausted`, any `rejected` delivery.\n3. Pulse table: every row has a `signal_date` inside the window and an openable `source_url`; stale dates are history and must not be expanded.\n4. `oxygen tables query <people_table> --limit 100 --json` — rows with neither an email nor a canonical `linkedin.com/in/` URL are held, never given an invented identity. Dedupe keys are `provider_id` (people), `event_key` (touchpoints) and the plan `upsert_key` (pulse).\n5. `oxygen budget list --json` and `oxygen limits show --json` — the resolved thresholds still match what you armed.\n\n## Learning loop\n\n- **What auto-files:** the table delivery ledger, workflow run receipts, enrollment receipts, sequence analytics, CRM stage changes and reply classifications.\n- **What to synthesise weekly:** positive-reply rate *by signal type and by lane* (a funding hit and a profile view are not one channel), by source and by band; band precision; time from event to first touch. `oxygen knowledge synthesize --kind campaign_learnings --sequence <sequence_id> --json`, read it, then re-run `--approved`.\n- **One rule per loop:** the band threshold, the segment, the pulse `--last-days` window, `--max-per-company`, one source on or off, or one cap — written with `oxygen knowledge log append --event decision --slug campaign-learnings --summary \"...\" --json`; a canonical change files a proposal.\n- **What you cannot change:** a type's `sourceWeight`. There is no per-workspace weighting surface, and pulse families carry none, so their ordering is yours to define in the queue you work.\n- Define \"good\" before arming: a positive reply, a booked meeting, or a closed deal. A loop with no written definition of good is automation, not a loop.\n\n## Go-live checklist\n\n1. `oxygen signals registry --json` + `oxygen signals list --json` — the live types, weights, baseline; `oxygen knowledge page get icp --json` reads `active`, with the segment written beside it.\n2. `oxygen senders health <sender_id> --json` — one usable sender, its warm-up day and daily caps; `oxygen crm setup --live --json` once.\n3. Pulse: `oxygen signals search plan --json` read in full (routes, dropped filters, `schedulable`), then dry-run, then the approved live run under a cap.\n4. Expansion: `oxygen people search plan --company-domains <csv> --max-per-company 3 --estimate --json`, dry-run, then the approved live run into the people table.\n5. Engagement: arm only what you have (`oxygen linkedin intent setup --account <sender_id> --json`, `oxygen viewers import --account <sender_id> --json`, `oxygen followers import --account <sender_id> --json`, the reveal webhook), each verified by its own status read.\n6. Per armed type: `oxygen blueprints apply crm-profile-viewer --json` → `oxygen workflows enable <workflow_id> --approved --max-credits <cap> --json`, confirmed by `oxygen crm activity timeline people <row_id> --json`.\n7. `oxygen signals leads-today --limit 10 --json` returns people carrying a type; `oxygen columns run <people_table> icp_fit --limit 10 --dry-run --json`, then the approved run, then one assessment read per band.\n8. `oxygen sequences start signal-based-outbound --json` previewed, then `--approved --max-live-sends <n>`; `oxygen linkedin intent autoenroll --account <sender_id> --sequence signal-based-outbound --json` previewed — read the audience split — then `--approved` with explicit caps.\n9. `oxygen crm automation rules --json` shows `crm-lead-stage-router` armed; `oxygen budget list --json` and `oxygen limits show --json` match what you armed; log it with `oxygen knowledge log append --event note --slug campaign-learnings --summary \"...\" --json`.\n\n## Open questions (state them, do not resolve them)\n\n- Pulse and product stream emission is unfinished: the registry carries no hiring / funding / tech / acquisition / news / product-usage type, so those harvests are table data nothing subscribes to. Stages 1–2 source and expand pulse hits natively and work them from the table; the stream/registry half is the unfinished end state.\n- Because pulse families carry no `sourceWeight`, the queue cannot interleave a funding hit with a profile view. Cross-lane ranking is a human judgement today; never present it as one ordered list the product produced.\n- `signals registry` is a read-only snapshot derived from code constants — no registry or subscription objects exist, so \"a workflow triggers on a signal\" means a blueprint bound to one type.\n- The suppression identity gap (LinkedIn provider ids only) leaves email-only reveals unchecked, and no `post_comment` type exists, so a comment ranks as a reaction.\n- No CLI command mints a self-serve inbound URL for a de-anonymization vendor, and no `/signals` web route renders the stream or the queue — both are parity gaps to record, not journeys to describe.\n- \"Arm every source at once\" is not ratified strategy. Start with one lane, one source and one bounded segment with a named downstream owner.\n\n## Resume point\n\n`oxygen signals registry --json` — which types exist, which are live, and where a pulse family's absence from the stream becomes visible. Then `oxygen signals list --json` (arriving), `oxygen feeds list --json` (armed), `oxygen tables list --json` (signal and expansion tables), `oxygen crm automation rules --json` plus `oxygen workflows list --json` (resolving and routing), and `oxygen sequences list --json` (sending). Start there, never from scratch.\n",
|
|
40
40
|
},
|
|
41
41
|
];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { OxygenError } from "./cli-result.js";
|
|
2
|
+
export declare const CUTOVER_FREEZE_ENV_VAR = "OXYGEN_CUTOVER_FREEZE";
|
|
3
|
+
/**
|
|
4
|
+
* The machine code every maintenance refusal carries, on every surface (web,
|
|
5
|
+
* CLI, MCP, public webhooks). Retryable by contract: nothing the caller sent was
|
|
6
|
+
* wrong, and the same request succeeds once the window closes.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MAINTENANCE_IN_PROGRESS_CODE = "maintenance_in_progress";
|
|
9
|
+
/** What `Retry-After` says during the window. Short enough that an agent loop resumes promptly. */
|
|
10
|
+
export declare const MAINTENANCE_RETRY_AFTER_SECONDS = 120;
|
|
11
|
+
/**
|
|
12
|
+
* Fixed customer copy (ADR 0023): says what is happening and what to do, names
|
|
13
|
+
* no host, database or vendor, and promises nothing about the exact end time.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAINTENANCE_IN_PROGRESS_MESSAGE = "OXYGEN is in a short scheduled maintenance window. Nothing was changed; retry in a few minutes.";
|
|
16
|
+
type FreezeEnv = {
|
|
17
|
+
[key: string]: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
export declare function isCutoverFreezeActive(env?: FreezeEnv): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The typed maintenance fault. `reason` is a stable machine token (never prose)
|
|
22
|
+
* that tells an operator which fenced path refused, e.g.
|
|
23
|
+
* `tenant_schema_migration_pending` or `tenant_database_suspended`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function maintenanceInProgressError(reason: string, details?: Record<string, unknown>): OxygenError;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The cutover write freeze, and the one place that reads `OXYGEN_CUTOVER_FREEZE`.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Moving production between database hosts has a short window
|
|
4
|
+
// in which every committed write must already be on the source when the final
|
|
5
|
+
// checkpoint is taken, and nothing may commit on the source afterwards. The
|
|
6
|
+
// operator sets `OXYGEN_CUTOVER_FREEZE=1` on the stack being retired; every
|
|
7
|
+
// writer asks this module and stands down (`ops/selfhost/cutover/RUNBOOK.md`).
|
|
8
|
+
//
|
|
9
|
+
// UNSET MEANS NOT FROZEN, deliberately and permanently: a variable nobody set
|
|
10
|
+
// must never take production into maintenance. The recognised truthy values are
|
|
11
|
+
// the ones the three pre-existing readers accepted (`1`, `true`, `yes`, `on`,
|
|
12
|
+
// `enabled`), so consolidating them here changes no environment's behaviour.
|
|
13
|
+
//
|
|
14
|
+
// Read per call rather than memoized at module load: tests drive both states,
|
|
15
|
+
// and a snapshot taken at import would make the freeze untestable.
|
|
16
|
+
import { OxygenError } from "./cli-result.js";
|
|
17
|
+
export const CUTOVER_FREEZE_ENV_VAR = "OXYGEN_CUTOVER_FREEZE";
|
|
18
|
+
/**
|
|
19
|
+
* The machine code every maintenance refusal carries, on every surface (web,
|
|
20
|
+
* CLI, MCP, public webhooks). Retryable by contract: nothing the caller sent was
|
|
21
|
+
* wrong, and the same request succeeds once the window closes.
|
|
22
|
+
*/
|
|
23
|
+
export const MAINTENANCE_IN_PROGRESS_CODE = "maintenance_in_progress";
|
|
24
|
+
/** What `Retry-After` says during the window. Short enough that an agent loop resumes promptly. */
|
|
25
|
+
export const MAINTENANCE_RETRY_AFTER_SECONDS = 120;
|
|
26
|
+
/**
|
|
27
|
+
* Fixed customer copy (ADR 0023): says what is happening and what to do, names
|
|
28
|
+
* no host, database or vendor, and promises nothing about the exact end time.
|
|
29
|
+
*/
|
|
30
|
+
export const MAINTENANCE_IN_PROGRESS_MESSAGE = "OXYGEN is in a short scheduled maintenance window. Nothing was changed; retry in a few minutes.";
|
|
31
|
+
const TRUTHY = new Set(["1", "true", "yes", "on", "enabled"]);
|
|
32
|
+
export function isCutoverFreezeActive(env = process.env) {
|
|
33
|
+
const value = env[CUTOVER_FREEZE_ENV_VAR]?.trim().toLowerCase();
|
|
34
|
+
return value !== undefined && TRUTHY.has(value);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The typed maintenance fault. `reason` is a stable machine token (never prose)
|
|
38
|
+
* that tells an operator which fenced path refused, e.g.
|
|
39
|
+
* `tenant_schema_migration_pending` or `tenant_database_suspended`.
|
|
40
|
+
*/
|
|
41
|
+
export function maintenanceInProgressError(reason, details = {}) {
|
|
42
|
+
return new OxygenError(MAINTENANCE_IN_PROGRESS_CODE, MAINTENANCE_IN_PROGRESS_MESSAGE, {
|
|
43
|
+
details: {
|
|
44
|
+
reason,
|
|
45
|
+
retryable: true,
|
|
46
|
+
retry_after_seconds: MAINTENANCE_RETRY_AFTER_SECONDS,
|
|
47
|
+
next_step: "Retry the same request after the maintenance window; nothing needs to change.",
|
|
48
|
+
...details,
|
|
49
|
+
},
|
|
50
|
+
exitCode: 1,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The companies that supply OXYGEN's managed data, keyed by OXYGEN provider id.
|
|
3
|
+
*
|
|
4
|
+
* Founder decision (Philipp, 2026-09-25, reversing the 2026-09-11 rule that hid
|
|
5
|
+
* the managed LinkedIn rail's backend): data suppliers are NAMED — on
|
|
6
|
+
* /subprocessors, in provider labels, and in run provenance. What stays out of
|
|
7
|
+
* every customer surface is unchanged (ADR 0023): vendor hosts, keys, internal
|
|
8
|
+
* COGS, and upstream error text. Name the supplier from this map; never copy a
|
|
9
|
+
* vendor string into a label, descriptor, or help text by hand.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free on purpose: browser bundles (the picker, the record page)
|
|
12
|
+
* import it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MANAGED_DATA_SUPPLIERS: {
|
|
15
|
+
/** The managed `scraper.*` LinkedIn rail. HarvestAPI is BYOK only and is not listed. */
|
|
16
|
+
readonly scraper: "Up2Data";
|
|
17
|
+
/** Company-page follower audiences, fulfilled by the vendor. */
|
|
18
|
+
readonly scrapeli: "ScrapeLi";
|
|
19
|
+
/** LinkedIn post datasets on OXYGEN's managed key. */
|
|
20
|
+
readonly brightdata: "Bright Data";
|
|
21
|
+
/** Indexed company and people data on OXYGEN's managed key. */
|
|
22
|
+
readonly crustdata: "Crustdata";
|
|
23
|
+
};
|
|
24
|
+
export type ManagedDataSupplierProvider = keyof typeof MANAGED_DATA_SUPPLIERS;
|
|
25
|
+
/**
|
|
26
|
+
* Customer-visible name of the managed LinkedIn data rail (`scraper.*`). Founder
|
|
27
|
+
* decision 2026-09-25: data-extraction labels say "Professional Network", while
|
|
28
|
+
* ids, routes and operation names keep `linkedin`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PROFESSIONAL_NETWORK_DATA_NAME = "Professional Network Data";
|
|
31
|
+
/** The supplier behind a provider's MANAGED lane, or null when OXYGEN names none. */
|
|
32
|
+
export declare function managedDataSupplier(provider: string | null | undefined): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* `name · supplied by <supplier>` for a provider whose supplier is not already
|
|
35
|
+
* its name; the name unchanged otherwise ("Bright Data" never reads "Bright Data
|
|
36
|
+
* · supplied by Bright Data").
|
|
37
|
+
*/
|
|
38
|
+
export declare function suppliedByLabel(name: string, provider: string | null | undefined): string;
|
|
39
|
+
/** "Professional Network Data · supplied by Up2Data" — the managed LinkedIn rail's label. */
|
|
40
|
+
export declare const PROFESSIONAL_NETWORK_DATA_LABEL: string;
|
|
41
|
+
/**
|
|
42
|
+
* How a supplier obtains an operation's data. Informational only: it never gates,
|
|
43
|
+
* prices, or routes anything.
|
|
44
|
+
*
|
|
45
|
+
* - `supplier_collected`: the supplier collects and provides the data.
|
|
46
|
+
* - `supplier_may_use_logged_in_sessions`: the data is reachable only while
|
|
47
|
+
* signed in (engagers, activity feeds, search, admin-only lists), so the
|
|
48
|
+
* supplier may use logged-in sessions it operates. No customer account is used.
|
|
49
|
+
*/
|
|
50
|
+
export type DataSourcingMethod = "supplier_collected" | "supplier_may_use_logged_in_sessions";
|
|
51
|
+
export type DataSourcing = {
|
|
52
|
+
supplier: string;
|
|
53
|
+
method: DataSourcingMethod;
|
|
54
|
+
note: string;
|
|
55
|
+
};
|
|
56
|
+
/** The disclosure an operation carries, in one sentence per method. */
|
|
57
|
+
export declare function dataSourcingFor(provider: ManagedDataSupplierProvider, method: DataSourcingMethod): DataSourcing;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The companies that supply OXYGEN's managed data, keyed by OXYGEN provider id.
|
|
3
|
+
*
|
|
4
|
+
* Founder decision (Philipp, 2026-09-25, reversing the 2026-09-11 rule that hid
|
|
5
|
+
* the managed LinkedIn rail's backend): data suppliers are NAMED — on
|
|
6
|
+
* /subprocessors, in provider labels, and in run provenance. What stays out of
|
|
7
|
+
* every customer surface is unchanged (ADR 0023): vendor hosts, keys, internal
|
|
8
|
+
* COGS, and upstream error text. Name the supplier from this map; never copy a
|
|
9
|
+
* vendor string into a label, descriptor, or help text by hand.
|
|
10
|
+
*
|
|
11
|
+
* Dependency-free on purpose: browser bundles (the picker, the record page)
|
|
12
|
+
* import it.
|
|
13
|
+
*/
|
|
14
|
+
export const MANAGED_DATA_SUPPLIERS = {
|
|
15
|
+
/** The managed `scraper.*` LinkedIn rail. HarvestAPI is BYOK only and is not listed. */
|
|
16
|
+
scraper: "Up2Data",
|
|
17
|
+
/** Company-page follower audiences, fulfilled by the vendor. */
|
|
18
|
+
scrapeli: "ScrapeLi",
|
|
19
|
+
/** LinkedIn post datasets on OXYGEN's managed key. */
|
|
20
|
+
brightdata: "Bright Data",
|
|
21
|
+
/** Indexed company and people data on OXYGEN's managed key. */
|
|
22
|
+
crustdata: "Crustdata",
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Customer-visible name of the managed LinkedIn data rail (`scraper.*`). Founder
|
|
26
|
+
* decision 2026-09-25: data-extraction labels say "Professional Network", while
|
|
27
|
+
* ids, routes and operation names keep `linkedin`.
|
|
28
|
+
*/
|
|
29
|
+
export const PROFESSIONAL_NETWORK_DATA_NAME = "Professional Network Data";
|
|
30
|
+
/** The supplier behind a provider's MANAGED lane, or null when OXYGEN names none. */
|
|
31
|
+
export function managedDataSupplier(provider) {
|
|
32
|
+
if (!provider || !Object.hasOwn(MANAGED_DATA_SUPPLIERS, provider))
|
|
33
|
+
return null;
|
|
34
|
+
return MANAGED_DATA_SUPPLIERS[provider];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `name · supplied by <supplier>` for a provider whose supplier is not already
|
|
38
|
+
* its name; the name unchanged otherwise ("Bright Data" never reads "Bright Data
|
|
39
|
+
* · supplied by Bright Data").
|
|
40
|
+
*/
|
|
41
|
+
export function suppliedByLabel(name, provider) {
|
|
42
|
+
const supplier = managedDataSupplier(provider);
|
|
43
|
+
if (!supplier || name.toLowerCase().includes(supplier.toLowerCase()))
|
|
44
|
+
return name;
|
|
45
|
+
return `${name} · supplied by ${supplier}`;
|
|
46
|
+
}
|
|
47
|
+
/** "Professional Network Data · supplied by Up2Data" — the managed LinkedIn rail's label. */
|
|
48
|
+
export const PROFESSIONAL_NETWORK_DATA_LABEL = suppliedByLabel(PROFESSIONAL_NETWORK_DATA_NAME, "scraper");
|
|
49
|
+
/** The disclosure an operation carries, in one sentence per method. */
|
|
50
|
+
export function dataSourcingFor(provider, method) {
|
|
51
|
+
const supplier = MANAGED_DATA_SUPPLIERS[provider];
|
|
52
|
+
return {
|
|
53
|
+
supplier,
|
|
54
|
+
method,
|
|
55
|
+
note: method === "supplier_collected"
|
|
56
|
+
? `Collected and provided by ${supplier}. No account of yours is used.`
|
|
57
|
+
: `Collected by ${supplier}, which may use signed-in sessions it operates. No account of yours is used.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -25,8 +25,12 @@ export type EnrichmentIntentSurfaces = {
|
|
|
25
25
|
capability?: string;
|
|
26
26
|
/** `columns add --preset <id>` / MCP preset. */
|
|
27
27
|
preset?: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Company field-catalog keys this row materialises: they point a field at its
|
|
30
|
+
* row and price a waterfall row. Never a preset flag — no preset takes a field
|
|
31
|
+
* list since the founder decision of 2026-09-24.
|
|
32
|
+
*/
|
|
33
|
+
companyFields?: readonly string[];
|
|
30
34
|
/** One-shot CLI verb, e.g. "find email". */
|
|
31
35
|
find?: string;
|
|
32
36
|
/** Managed Functions library id (apps/web/src/lib/managed-functions/catalog.ts). */
|
|
@@ -186,20 +186,6 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
186
186
|
status: "selectable",
|
|
187
187
|
statusReason: "Runs today as the optional verification step of the Phone column; a standalone phone-verification column is a follow-up.",
|
|
188
188
|
},
|
|
189
|
-
{
|
|
190
|
-
id: "email_hashes",
|
|
191
|
-
label: "Email hashes",
|
|
192
|
-
description: "Hashes an email column (SHA-256, MD5) for ad-audience uploads without sending anything anywhere.",
|
|
193
|
-
entity: "person",
|
|
194
|
-
group: "contact",
|
|
195
|
-
kind: "formula",
|
|
196
|
-
rank: 90,
|
|
197
|
-
aliases: ["email hash", "sha256", "md5", "ad audience", "custom audience"],
|
|
198
|
-
inputShapes: ["email"],
|
|
199
|
-
outputFields: [f("email_sha256", "Email SHA-256", null), f("email_md5", "Email MD5", null)],
|
|
200
|
-
surfaces: { preset: "email_hashes", catalogEntry: "preset:email_hashes" },
|
|
201
|
-
status: "default",
|
|
202
|
-
},
|
|
203
189
|
// ── Company ───────────────────────────────────────────────────────────────
|
|
204
190
|
{
|
|
205
191
|
id: "enrich_company:identity",
|
|
@@ -212,7 +198,7 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
212
198
|
aliases: ["company domain", "company website", "website domain", "company linkedin page", "resolve domain", "company url"],
|
|
213
199
|
inputShapes: ["name", "domain", "linkedin_url"],
|
|
214
200
|
outputFields: [f("company_domain", "Domain", "company.domain"), f("company_linkedin_url", "LinkedIn URL", "company.linkedin_url")],
|
|
215
|
-
surfaces: { preset: "company_enrich",
|
|
201
|
+
surfaces: { preset: "company_enrich", companyFields: ["domain", "linkedin_url"], find: "find company", managedFunction: "company_domain", catalogEntry: "preset:company_enrich", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:domain", "field:linkedin_url"] },
|
|
216
202
|
status: "default",
|
|
217
203
|
decision: "D48",
|
|
218
204
|
},
|
|
@@ -227,7 +213,7 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
227
213
|
aliases: ["headcount", "employee count", "how many employees", "company size", "employees"],
|
|
228
214
|
inputShapes: ["domain", "linkedin_url"],
|
|
229
215
|
outputFields: [f("company_headcount", "Headcount", "company.employee_count", "numeric"), f("company_employee_range", "Size band", "company.employee_range", "text", true)],
|
|
230
|
-
surfaces: { preset: "company_enrich",
|
|
216
|
+
surfaces: { preset: "company_enrich", companyFields: ["headcount"], managedFunction: "company_employee_count", catalogEntry: "preset:company_enrich", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:headcount"] },
|
|
231
217
|
status: "default",
|
|
232
218
|
decision: "D48",
|
|
233
219
|
},
|
|
@@ -242,7 +228,7 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
242
228
|
aliases: ["company industry", "which industry", "sector", "what industry"],
|
|
243
229
|
inputShapes: ["domain", "linkedin_url"],
|
|
244
230
|
outputFields: [f("company_industry", "Industry", "company.industry")],
|
|
245
|
-
surfaces: { preset: "company_enrich",
|
|
231
|
+
surfaces: { preset: "company_enrich", companyFields: ["industry"], managedFunction: "company_industry", catalogEntry: "preset:company_enrich", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:industry"] },
|
|
246
232
|
status: "default",
|
|
247
233
|
decision: "D48",
|
|
248
234
|
},
|
|
@@ -266,7 +252,7 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
266
252
|
f("company_specialties", "Specialties", "company.specialties", "jsonb", true),
|
|
267
253
|
f("company_logo_url", "Logo", "company.logo_url", "text", true),
|
|
268
254
|
],
|
|
269
|
-
surfaces: { preset: "company_enrich",
|
|
255
|
+
surfaces: { preset: "company_enrich", companyFields: ["company_profile", "description", "founded_year", "hq_address", "hq_country", "company_type", "specialties", "logo_url"], managedFunction: "enrich_company", catalogEntry: "preset:company_enrich", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:company_profile"] },
|
|
270
256
|
status: "default",
|
|
271
257
|
decision: "D48",
|
|
272
258
|
},
|
|
@@ -288,7 +274,9 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
288
274
|
f("company_funding_rounds_count", "Rounds", null, "numeric", true),
|
|
289
275
|
f("company_investors", "Investors", null, "jsonb", true),
|
|
290
276
|
],
|
|
291
|
-
|
|
277
|
+
// Not in the company bundle (founder, 2026-09-24: one profile lookup, no
|
|
278
|
+
// opt-in fields); a caller names the fields on `find company`.
|
|
279
|
+
surfaces: { find: "find company --domain <domain> --fields funding,funding_stage,latest_funding_round,total_funding,funding_rounds_count,investors", companyFields: ["funding", "funding_stage", "latest_funding_round", "total_funding", "funding_rounds_count", "investors"], managedFunction: "company_total_funding", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:funding"] },
|
|
292
280
|
status: "default",
|
|
293
281
|
decision: "D49",
|
|
294
282
|
},
|
|
@@ -303,7 +291,7 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
303
291
|
aliases: ["tech stack", "technologies they use", "technographics", "tools they use", "software they use", "built with"],
|
|
304
292
|
inputShapes: ["domain"],
|
|
305
293
|
outputFields: [f("company_technologies", "Technologies", "company.technologies", "jsonb"), f("company_technology_check", "Uses named technology", null, "boolean", true)],
|
|
306
|
-
surfaces: { preset: "company_tech_stack",
|
|
294
|
+
surfaces: { preset: "company_tech_stack", companyFields: ["technologies", "technology_check"], managedFunction: "company_tech_stack", catalogEntry: "preset:company_tech_stack", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:technologies"] },
|
|
307
295
|
status: "default",
|
|
308
296
|
decision: "D49",
|
|
309
297
|
},
|
|
@@ -318,7 +306,8 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
318
306
|
aliases: ["open roles", "job openings", "are they hiring", "is hiring", "job postings", "hiring signals"],
|
|
319
307
|
inputShapes: ["domain", "linkedin_url"],
|
|
320
308
|
outputFields: [f("company_hiring_signals", "Hiring signals", null, "jsonb"), f("company_job_openings", "Open roles", null, "numeric", true)],
|
|
321
|
-
|
|
309
|
+
// Not in the company bundle (founder, 2026-09-24).
|
|
310
|
+
surfaces: { find: "find company --domain <domain> --fields hiring_signals,job_openings", companyFields: ["hiring_signals", "job_openings"], managedFunction: "company_job_openings", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:hiring_signals"] },
|
|
322
311
|
status: "default",
|
|
323
312
|
},
|
|
324
313
|
{
|
|
@@ -443,7 +432,8 @@ export const ENRICHMENT_INTENT_TAXONOMY = [
|
|
|
443
432
|
aliases: ["revenue", "annual revenue", "revenue estimate", "how much do they make", "arr", "reported revenue", "exact revenue", "financials", "company revenue"],
|
|
444
433
|
inputShapes: ["domain"],
|
|
445
434
|
outputFields: [f("company_revenue", "Revenue (estimate)", "company.revenue")],
|
|
446
|
-
|
|
435
|
+
// Not in the company bundle (founder, 2026-09-24).
|
|
436
|
+
surfaces: { find: "find company --domain <domain> --fields revenue", companyFields: ["revenue"], managedFunction: "company_revenue_estimate", registryFunction: "enrichment", registryIntent: "enrich_company", registryInputShapes: ["field:revenue"] },
|
|
447
437
|
status: "default",
|
|
448
438
|
decision: "D50",
|
|
449
439
|
},
|
|
@@ -796,7 +786,7 @@ export function catalogPointerForEntry(entryId) {
|
|
|
796
786
|
}
|
|
797
787
|
/** Catalogue pointer for one company enrichment field key such as `technologies` or `latest_funding_round`. */
|
|
798
788
|
export function catalogPointerForCompanyField(field) {
|
|
799
|
-
return pointerForRows(ENRICHMENT_INTENT_TAXONOMY.filter((row) => row.surfaces.
|
|
789
|
+
return pointerForRows(ENRICHMENT_INTENT_TAXONOMY.filter((row) => row.surfaces.companyFields?.includes(field) === true));
|
|
800
790
|
}
|
|
801
791
|
export function findEnrichmentIntentByAlias(query) {
|
|
802
792
|
const normalized = query.toLowerCase().replace(/[^a-z0-9&'\s-]/g, " ").replace(/\s+/g, " ").trim();
|
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
*/
|
|
23
23
|
export type HostedAiLevel = "low" | "medium" | "high";
|
|
24
24
|
export type HostedAiUseCase = "ai_column" | "copilot" | "agent";
|
|
25
|
+
/**
|
|
26
|
+
* The Copilot's own level set. `auto` is the only tier a customer can land on
|
|
27
|
+
* since 2026-09-21 ("Oxygen Auto"); low/medium/high survive because sessions
|
|
28
|
+
* created before that release carry them in `copilot_sessions.reasoning_level`
|
|
29
|
+
* and must keep resolving to the model they were priced against.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT folded into `HostedAiLevel`: that type is shared with
|
|
32
|
+
* `AiReasoningLevel` and with the ai_column/agent registries, neither of which
|
|
33
|
+
* has an `auto` entry, so widening it would force two fictional rows.
|
|
34
|
+
*/
|
|
35
|
+
export type CopilotAiLevel = HostedAiLevel | "auto";
|
|
25
36
|
export type HostedAiModelSpec = {
|
|
26
37
|
/** OpenRouter model id, e.g. "deepseek/deepseek-v4-flash". */
|
|
27
38
|
model: string;
|
|
@@ -44,7 +55,11 @@ export type HostedAiModelSpec = {
|
|
|
44
55
|
/** Upper bound on completion tokens requested for this tier. */
|
|
45
56
|
maxOutputTokens: number;
|
|
46
57
|
};
|
|
47
|
-
export declare const HOSTED_AI_MODEL_REGISTRY:
|
|
58
|
+
export declare const HOSTED_AI_MODEL_REGISTRY: {
|
|
59
|
+
ai_column: Record<HostedAiLevel, HostedAiModelSpec>;
|
|
60
|
+
copilot: Record<CopilotAiLevel, HostedAiModelSpec>;
|
|
61
|
+
agent: Record<HostedAiLevel, HostedAiModelSpec>;
|
|
62
|
+
};
|
|
48
63
|
/**
|
|
49
64
|
* What a MANAGED AI column's reasoning tier is called in front of a customer.
|
|
50
65
|
*
|
|
@@ -60,8 +75,49 @@ export declare const HOSTED_AI_MODEL_REGISTRY: Record<HostedAiUseCase, Record<Ho
|
|
|
60
75
|
* their own key, so the real model id is the correct thing to show.
|
|
61
76
|
*/
|
|
62
77
|
export declare const MANAGED_AI_TIER_LABELS: Record<HostedAiLevel, string>;
|
|
63
|
-
/**
|
|
64
|
-
|
|
78
|
+
/**
|
|
79
|
+
* What the Copilot calls its tiers.
|
|
80
|
+
*
|
|
81
|
+
* Separate from MANAGED_AI_TIER_LABELS rather than folded into it because the
|
|
82
|
+
* Copilot's level set is not the AI column's: only the Copilot has `auto`, and
|
|
83
|
+
* only the Copilot has stopped offering a choice. AI columns and Agents still
|
|
84
|
+
* sell Fast/Balanced/Max and still mean it.
|
|
85
|
+
*
|
|
86
|
+
* Every current session reads "Oxygen Auto". The other three exist so a session
|
|
87
|
+
* created before 2026-09-21 still renders as the thing its owner picked, rather
|
|
88
|
+
* than being relabelled under them.
|
|
89
|
+
*/
|
|
90
|
+
export declare const COPILOT_TIER_LABELS: Record<CopilotAiLevel, string>;
|
|
91
|
+
/**
|
|
92
|
+
* The tier every new Copilot session gets. There is no longer a picker: "Oxygen
|
|
93
|
+
* Auto" is the whole customer-facing choice (Philipp, 2026-09-21).
|
|
94
|
+
*
|
|
95
|
+
* It was `high` until then, which is why 83% of production sessions ran the most
|
|
96
|
+
* expensive tier -- 110 of 132 sessions across 69 of 81 tenants -- while the
|
|
97
|
+
* composer's own dropdown told the user that medium was "The default for most
|
|
98
|
+
* GTM work". Nobody chose that; we defaulted them into it.
|
|
99
|
+
*/
|
|
100
|
+
export declare const COPILOT_DEFAULT_LEVEL: CopilotAiLevel;
|
|
101
|
+
/**
|
|
102
|
+
* The tier the Copilot's ERRANDS run at: sub-agent children, compaction
|
|
103
|
+
* summaries, and auto-titles.
|
|
104
|
+
*
|
|
105
|
+
* This is the only place a model may differ from the session's own, and it is
|
|
106
|
+
* safe for one reason: each of those builds a FRESH context rather than
|
|
107
|
+
* continuing the main transcript -- a child constructs its own Agent and
|
|
108
|
+
* messages (`packages/agent-runtime/src/subagents.ts`), and the summarizer sends
|
|
109
|
+
* a two-message prompt with no tools
|
|
110
|
+
* (`packages/agent-runtime/src/strands-runtime.ts` summarizeForCompaction). A
|
|
111
|
+
* cold context has no prompt cache to lose and no transcript to corrupt, so the
|
|
112
|
+
* switch costs nothing. Switching the main thread would cost both.
|
|
113
|
+
*
|
|
114
|
+
* It matters because delegation is habitual and currently doubles the price of a
|
|
115
|
+
* turn: measured over 30 days to 2026-09-21, the 26% of turns that fired
|
|
116
|
+
* `subagent_run` produced 45% of all Copilot credit burn (1,805 credits/turn
|
|
117
|
+
* against 786), because every child inherited the parent's model through the
|
|
118
|
+
* parent's own `callModel`.
|
|
119
|
+
*/
|
|
120
|
+
export declare const COPILOT_ERRAND_LEVEL: CopilotAiLevel;
|
|
65
121
|
export declare const AGENT_DEFAULT_LEVEL: HostedAiLevel;
|
|
66
122
|
/**
|
|
67
123
|
* Resolve the model spec for a hosted-AI use case + reasoning level.
|
|
@@ -87,7 +143,7 @@ export declare const AGENT_DEFAULT_LEVEL: HostedAiLevel;
|
|
|
87
143
|
export declare function findHostedAiModelSpec(model: string, preferredUseCase?: HostedAiUseCase): HostedAiModelSpec | null;
|
|
88
144
|
export declare function resolveHostedAiModel(input: {
|
|
89
145
|
useCase: HostedAiUseCase;
|
|
90
|
-
level:
|
|
146
|
+
level: CopilotAiLevel;
|
|
91
147
|
env?: Record<string, string | undefined>;
|
|
92
148
|
}): HostedAiModelSpec;
|
|
93
149
|
/**
|