@oxygen-agent/cli 1.1010.721 → 1.1010.905
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/auto-update.d.ts +129 -0
- package/dist/auto-update.js +392 -0
- package/dist/command-manifest.js +14 -0
- package/dist/credentials.d.ts +2 -0
- package/dist/credentials.js +6 -3
- package/dist/functions-commands.js +1 -1
- package/dist/http-client.js +28 -4
- package/dist/index.js +583 -145
- package/dist/run-wait.d.ts +3 -1
- package/dist/run-wait.js +19 -5
- package/dist/streamed-file-import.d.ts +58 -0
- package/dist/streamed-file-import.js +115 -0
- package/dist/update.d.ts +29 -0
- package/dist/update.js +62 -16
- package/dist/workflow-plan-limit-notices.d.ts +8 -0
- package/dist/workflow-plan-limit-notices.js +28 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +3 -3
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +27 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +191 -35
- package/node_modules/@oxygen/shared/dist/billing.js +333 -42
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +55 -5
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +2 -2
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +2 -2
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate-view.js +90 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +167 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +361 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/credit-gate.js +65 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.d.ts +51 -0
- package/node_modules/@oxygen/shared/dist/email-deliverability-policy.js +101 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +3 -1
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +3 -3
- package/node_modules/@oxygen/shared/dist/error-redaction.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.js +5 -0
- package/node_modules/@oxygen/shared/dist/file-import.d.ts +13 -1
- package/node_modules/@oxygen/shared/dist/file-import.js +33 -6
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +73 -3
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +246 -24
- package/node_modules/@oxygen/shared/dist/import-limits.d.ts +25 -1
- package/node_modules/@oxygen/shared/dist/import-limits.js +35 -2
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -22
- package/node_modules/@oxygen/shared/dist/index.js +2 -42
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +17 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +55 -0
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +117 -1
- package/node_modules/@oxygen/shared/dist/plan-band.js +175 -10
- package/node_modules/@oxygen/shared/dist/plan-capabilities.d.ts +77 -7
- package/node_modules/@oxygen/shared/dist/plan-capabilities.js +87 -7
- package/node_modules/@oxygen/shared/dist/plan-limits-view.d.ts +219 -0
- package/node_modules/@oxygen/shared/dist/plan-limits-view.js +330 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +204 -6
- package/node_modules/@oxygen/shared/dist/plan-limits.js +197 -15
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +80 -36
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +80 -31
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +38 -20
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +47 -34
- package/node_modules/@oxygen/shared/dist/provider-http-error.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/provider-http-error.js +27 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +127 -0
- package/node_modules/@oxygen/shared/dist/repricing.js +407 -6
- package/node_modules/@oxygen/shared/dist/semver.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/semver.js +41 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +5 -7
- package/node_modules/@oxygen/shared/dist/sending-limits.js +10 -16
- package/node_modules/@oxygen/shared/dist/sending-seats.d.ts +18 -15
- package/node_modules/@oxygen/shared/dist/sending-seats.js +22 -17
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +57 -8
- package/node_modules/@oxygen/shared/dist/spend-safety.js +64 -11
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +15 -7
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +18 -5
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +34 -5
- package/node_modules/@oxygen/shared/dist/table-capacity.js +25 -8
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +13 -5
- package/node_modules/@oxygen/shared/dist/telemetry-resource.d.ts +40 -0
- package/node_modules/@oxygen/shared/dist/telemetry-resource.js +35 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +5 -0
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/ugc.js +29 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -3
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +14 -27
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +5 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +5 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +3 -3
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +15 -1
- package/node_modules/@oxygen/workflows/dist/graph/types.js +15 -1
- package/node_modules/@oxygen/workflows/dist/index.d.ts +45 -0
- package/node_modules/@oxygen/workflows/dist/index.js +152 -2
- package/node_modules/@oxygen/workflows/dist/usage-estimate.d.ts +10 -1
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +33 -29
- package/package.json +1 -1
|
@@ -26,6 +26,6 @@ export declare const COPILOT_SKILL_SNAPSHOTS_GENERATED: readonly [{
|
|
|
26
26
|
readonly slug: "signal-based-outbound";
|
|
27
27
|
readonly title: "Playbook: Signal-based outbound";
|
|
28
28
|
readonly sources: readonly ["oxygen-playbooks/playbooks/signal-based-outbound.md"];
|
|
29
|
-
readonly sha256: "
|
|
30
|
-
readonly 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";
|
|
29
|
+
readonly sha256: "b29bcaf04707f4aee4cb0fdafa6a0ee02cff7f0987ee1e55cf313ebadb76454b";
|
|
30
|
+
readonly 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 size's per-delivery default (the preview prints which; `oxygen limits show --json` → `spend_safety_defaults`). `--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";
|
|
31
31
|
}];
|
|
@@ -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>` (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",
|
|
38
|
+
sha256: "b29bcaf04707f4aee4cb0fdafa6a0ee02cff7f0987ee1e55cf313ebadb76454b",
|
|
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 size's per-delivery default (the preview prints which; `oxygen limits show --json` → `spend_safety_defaults`). `--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,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `POST /api/cli/billing/estimate` reads to a person. The MCP
|
|
3
|
+
* `oxygen.cost-estimate` widget and the web Settings → Plan limits → Estimate a
|
|
4
|
+
* month view both render from here, so a label or a rounding rule cannot differ
|
|
5
|
+
* between them. Pure and browser-safe: it formats an envelope it is handed.
|
|
6
|
+
*/
|
|
7
|
+
import type { CostEstimateResult } from "./cost-estimate.js";
|
|
8
|
+
export type CostEstimateEnvelope = Partial<CostEstimateResult> & {
|
|
9
|
+
compared_plan?: string;
|
|
10
|
+
compared_plan_source?: "workspace" | "requested";
|
|
11
|
+
sender_billing?: "sending_seats" | "credit_reservations";
|
|
12
|
+
pricing_schedule?: {
|
|
13
|
+
effective_at?: string;
|
|
14
|
+
note?: string;
|
|
15
|
+
};
|
|
16
|
+
web_url?: string;
|
|
17
|
+
deepLink?: string;
|
|
18
|
+
};
|
|
19
|
+
export type CostEstimateViewRow = {
|
|
20
|
+
key: string;
|
|
21
|
+
label: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
monthly: string;
|
|
24
|
+
worstCase: string;
|
|
25
|
+
};
|
|
26
|
+
export type CostEstimateViewPlan = {
|
|
27
|
+
label: string;
|
|
28
|
+
expected: string;
|
|
29
|
+
worstCase: string;
|
|
30
|
+
};
|
|
31
|
+
export type CostEstimateViewLimit = {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
usage: string;
|
|
35
|
+
hit: boolean;
|
|
36
|
+
note: string;
|
|
37
|
+
};
|
|
38
|
+
export type CostEstimateView = {
|
|
39
|
+
summary: string;
|
|
40
|
+
rows: CostEstimateViewRow[];
|
|
41
|
+
totals: {
|
|
42
|
+
label: string;
|
|
43
|
+
expected: string;
|
|
44
|
+
worstCase: string;
|
|
45
|
+
}[];
|
|
46
|
+
plans: CostEstimateViewPlan[];
|
|
47
|
+
limits: CostEstimateViewLimit[];
|
|
48
|
+
notes: string[];
|
|
49
|
+
};
|
|
50
|
+
export declare function costEstimateView(envelope: CostEstimateEnvelope): CostEstimateView;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function credits(value) {
|
|
2
|
+
if (value === null || value === undefined)
|
|
3
|
+
return "—";
|
|
4
|
+
return `${value.toLocaleString("en-US", { maximumFractionDigits: 2 })} cr`;
|
|
5
|
+
}
|
|
6
|
+
function usd(value) {
|
|
7
|
+
if (value === null || value === undefined)
|
|
8
|
+
return "—";
|
|
9
|
+
return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
10
|
+
}
|
|
11
|
+
function lineDetail(line) {
|
|
12
|
+
const quantity = line.quantity.toLocaleString("en-US");
|
|
13
|
+
if (line.billed_as === "free")
|
|
14
|
+
return `${quantity} × 0 cr`;
|
|
15
|
+
if (line.billed_as === "usd") {
|
|
16
|
+
const unit = line.quantity > 0 && line.usd_per_month !== null ? line.usd_per_month / line.quantity : null;
|
|
17
|
+
return `${quantity} × ${usd(unit)} seat`;
|
|
18
|
+
}
|
|
19
|
+
if (line.unit_credits === null)
|
|
20
|
+
return `${quantity} × no price yet`;
|
|
21
|
+
const worst = line.worst_case_unit_credits !== null && line.worst_case_unit_credits !== line.unit_credits
|
|
22
|
+
? ` (up to ${credits(line.worst_case_unit_credits)})`
|
|
23
|
+
: "";
|
|
24
|
+
const scheduled = line.scheduled_unit_credits !== undefined && line.scheduled_unit_credits !== null
|
|
25
|
+
? `; ${credits(line.scheduled_unit_credits)} from the scheduled date`
|
|
26
|
+
: "";
|
|
27
|
+
return `${quantity} × ${credits(line.unit_credits)}${worst}${scheduled}`;
|
|
28
|
+
}
|
|
29
|
+
function lineMonthly(line, worst) {
|
|
30
|
+
if (line.billed_as === "usd")
|
|
31
|
+
return usd(line.usd_per_month);
|
|
32
|
+
return credits(worst ? line.worst_case_credits_per_month : line.credits_per_month);
|
|
33
|
+
}
|
|
34
|
+
function planText(outcome) {
|
|
35
|
+
const total = outcome.monthly_usd === null ? "" : `${usd(outcome.monthly_usd)} a month all in`;
|
|
36
|
+
if (outcome.covered)
|
|
37
|
+
return total ? `Covers it: ${total}` : "Covers it";
|
|
38
|
+
const topup = `short ${credits(outcome.shortfall_credits)}, top up ${credits(outcome.topup_credits)} (${usd(outcome.topup_usd)})`;
|
|
39
|
+
return total ? `${total}, ${topup}` : topup;
|
|
40
|
+
}
|
|
41
|
+
export function costEstimateView(envelope) {
|
|
42
|
+
const plans = [];
|
|
43
|
+
if (envelope.plan_fit) {
|
|
44
|
+
const suffix = envelope.compared_plan_source === "requested" ? "" : " (yours)";
|
|
45
|
+
plans.push({
|
|
46
|
+
label: `${envelope.plan_fit.expected.label}${suffix}`,
|
|
47
|
+
expected: planText(envelope.plan_fit.expected),
|
|
48
|
+
worstCase: planText(envelope.plan_fit.worst_case),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (envelope.recommended_plan) {
|
|
52
|
+
const { expected, worst_case: worstCase } = envelope.recommended_plan;
|
|
53
|
+
plans.push({
|
|
54
|
+
label: "Cheapest plan",
|
|
55
|
+
expected: `${expected.label}: ${planText(expected)}`,
|
|
56
|
+
worstCase: `${worstCase.label}: ${planText(worstCase)}`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const totals = envelope.totals;
|
|
60
|
+
return {
|
|
61
|
+
summary: envelope.summary ?? "",
|
|
62
|
+
rows: (envelope.lines ?? []).map((line) => ({
|
|
63
|
+
key: line.key,
|
|
64
|
+
label: line.label,
|
|
65
|
+
detail: lineDetail(line),
|
|
66
|
+
monthly: lineMonthly(line, false),
|
|
67
|
+
worstCase: lineMonthly(line, true),
|
|
68
|
+
})),
|
|
69
|
+
totals: totals
|
|
70
|
+
? [
|
|
71
|
+
{ label: "Credits a month", expected: credits(totals.credits_per_month), worstCase: credits(totals.worst_case_credits_per_month) },
|
|
72
|
+
...(totals.usd_seats_per_month > 0
|
|
73
|
+
? [{ label: "Sending seats (card)", expected: usd(totals.usd_seats_per_month), worstCase: usd(totals.usd_seats_per_month) }]
|
|
74
|
+
: []),
|
|
75
|
+
]
|
|
76
|
+
: [],
|
|
77
|
+
plans,
|
|
78
|
+
limits: (envelope.limits ?? []).map((row) => ({
|
|
79
|
+
key: row.key,
|
|
80
|
+
label: row.label,
|
|
81
|
+
usage: `${row.needed_per_month.toLocaleString("en-US")} of ${row.capacity_per_month.toLocaleString("en-US")} a month`,
|
|
82
|
+
hit: row.hit,
|
|
83
|
+
note: row.note,
|
|
84
|
+
})),
|
|
85
|
+
notes: [
|
|
86
|
+
...(envelope.warnings ?? []),
|
|
87
|
+
...(envelope.pricing_schedule?.note ? [envelope.pricing_schedule.note] : []),
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario cost estimator behind `POST /api/cli/billing/estimate`
|
|
3
|
+
* (`oxygen billing estimate`, MCP `oxygen_billing_estimate`, Settings → Plan
|
|
4
|
+
* limits → Estimate a month). OXP-43.33.
|
|
5
|
+
*
|
|
6
|
+
* A founder asks "2,000 leads a month, work email on each, a 3-step email and
|
|
7
|
+
* LinkedIn sequence from 2 mailboxes and 1 LinkedIn account: what does that
|
|
8
|
+
* cost, and does anything run out?". Before this, an agent answered it by
|
|
9
|
+
* stitching six reads together by hand (blind baseline 2026-09-26, finding 9).
|
|
10
|
+
*
|
|
11
|
+
* Pure: the route resolves every price from the live pricing book, the
|
|
12
|
+
* enrichment catalog and the sending limits the send path enforces, and hands
|
|
13
|
+
* them in as a price sheet. Nothing here knows a price, so the estimate can
|
|
14
|
+
* never quote a number the product does not charge.
|
|
15
|
+
*
|
|
16
|
+
* Three things are kept apart on purpose, because they are paid differently:
|
|
17
|
+
* - credits that recur every month (sender reservations, enrichment), which
|
|
18
|
+
* size the plan;
|
|
19
|
+
* - dollars that recur on a card (sending seats while seats are sold), which a
|
|
20
|
+
* plan's credits never pay for;
|
|
21
|
+
* - sending itself, which is free and is bounded by per-account limits, not by
|
|
22
|
+
* money.
|
|
23
|
+
*/
|
|
24
|
+
export declare const COST_ESTIMATE_BOUNDS: {
|
|
25
|
+
readonly maxLeadsPerMonth: 10000000;
|
|
26
|
+
readonly maxStepsPerChannel: 20;
|
|
27
|
+
readonly maxSendersPerKind: 1000;
|
|
28
|
+
readonly maxEnrichments: 12;
|
|
29
|
+
};
|
|
30
|
+
export type CostEstimateScenario = {
|
|
31
|
+
leadsPerMonth: number;
|
|
32
|
+
/** Enrichment catalog ids, each run once on every lead. */
|
|
33
|
+
enrichmentIds: readonly string[];
|
|
34
|
+
emailSteps: number;
|
|
35
|
+
/** The first LinkedIn step is a connection invite; later ones are messages. */
|
|
36
|
+
linkedinSteps: number;
|
|
37
|
+
/** Mailboxes you connect yourself (Google, Microsoft, SMTP). */
|
|
38
|
+
mailboxes: number;
|
|
39
|
+
/** Mailboxes OXYGEN sells you (managed inboxes). */
|
|
40
|
+
managedMailboxes: number;
|
|
41
|
+
linkedinAccounts: number;
|
|
42
|
+
};
|
|
43
|
+
export type CostEstimateEnrichmentPrice = {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
kind: "fixed" | "estimated" | "unavailable";
|
|
47
|
+
expectedCreditsPerRow: number | null;
|
|
48
|
+
worstCaseCreditsPerRow: number | null;
|
|
49
|
+
hitRatesAssumed: boolean;
|
|
50
|
+
};
|
|
51
|
+
export type CostEstimateSenderPrice = {
|
|
52
|
+
billedAs: "credits";
|
|
53
|
+
/** null: no price in force, so nothing is reserved yet. */
|
|
54
|
+
creditsPerMonth: number | null;
|
|
55
|
+
/** The reservation from the scheduled repricing date, when that differs. */
|
|
56
|
+
scheduledCreditsPerMonth?: number | null;
|
|
57
|
+
} | {
|
|
58
|
+
billedAs: "usd";
|
|
59
|
+
usdCentsPerMonth: number;
|
|
60
|
+
};
|
|
61
|
+
export type CostEstimatePlanRung = {
|
|
62
|
+
planKey: string;
|
|
63
|
+
label: string;
|
|
64
|
+
monthlyPriceCents: number | null;
|
|
65
|
+
monthlyCredits: number;
|
|
66
|
+
};
|
|
67
|
+
export type CostEstimatePriceSheet = {
|
|
68
|
+
/** One entry per selected enrichment id, in the scenario's order. */
|
|
69
|
+
enrichments: readonly CostEstimateEnrichmentPrice[];
|
|
70
|
+
senders: {
|
|
71
|
+
mailbox: CostEstimateSenderPrice;
|
|
72
|
+
managedMailbox: CostEstimateSenderPrice;
|
|
73
|
+
linkedinAccount: CostEstimateSenderPrice;
|
|
74
|
+
};
|
|
75
|
+
/** The plans a customer can buy, cheapest first. */
|
|
76
|
+
rungs: readonly CostEstimatePlanRung[];
|
|
77
|
+
/** The plan the estimate is checked against (the workspace's own by default). */
|
|
78
|
+
comparePlan: CostEstimatePlanRung | null;
|
|
79
|
+
creditsPerUsd: number;
|
|
80
|
+
topupUsdCentsPer100: number;
|
|
81
|
+
topupMinCredits: number;
|
|
82
|
+
topupStepCredits: number;
|
|
83
|
+
sending: {
|
|
84
|
+
emailPerMailboxPerDay: number;
|
|
85
|
+
emailRamp: readonly {
|
|
86
|
+
throughDay: number;
|
|
87
|
+
perDay: number;
|
|
88
|
+
}[];
|
|
89
|
+
linkedinInvitesPerDay: number;
|
|
90
|
+
linkedinInvitesPerWeek: number;
|
|
91
|
+
linkedinMessagesPerDay: number;
|
|
92
|
+
sendingDaysPerMonth: number;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
export type CostEstimateLine = {
|
|
96
|
+
key: string;
|
|
97
|
+
group: "enrichment" | "senders" | "sending";
|
|
98
|
+
label: string;
|
|
99
|
+
quantity: number;
|
|
100
|
+
unit: string;
|
|
101
|
+
/** `credits` draws on the plan, `usd` bills a card, `free` costs nothing. */
|
|
102
|
+
billed_as: "credits" | "usd" | "free";
|
|
103
|
+
unit_credits: number | null;
|
|
104
|
+
worst_case_unit_credits: number | null;
|
|
105
|
+
credits_per_month: number | null;
|
|
106
|
+
worst_case_credits_per_month: number | null;
|
|
107
|
+
/** A `usd` line's card charge; a credit line's credits at face value. */
|
|
108
|
+
usd_per_month: number | null;
|
|
109
|
+
scheduled_unit_credits?: number | null;
|
|
110
|
+
note?: string;
|
|
111
|
+
};
|
|
112
|
+
export type CostEstimatePlanOutcome = {
|
|
113
|
+
plan_key: string;
|
|
114
|
+
label: string;
|
|
115
|
+
monthly_price_usd: number | null;
|
|
116
|
+
monthly_credits: number;
|
|
117
|
+
covered: boolean;
|
|
118
|
+
/** Credits the plan does not cover, before rounding to a purchasable top-up. */
|
|
119
|
+
shortfall_credits: number;
|
|
120
|
+
topup_credits: number;
|
|
121
|
+
topup_usd: number;
|
|
122
|
+
/** Plan price + top-ups + seats billed in dollars. */
|
|
123
|
+
monthly_usd: number | null;
|
|
124
|
+
};
|
|
125
|
+
export type CostEstimateLimit = {
|
|
126
|
+
key: "email_per_mailbox" | "linkedin_invites_per_account" | "linkedin_messages_per_account";
|
|
127
|
+
label: string;
|
|
128
|
+
needed_per_month: number;
|
|
129
|
+
senders: number;
|
|
130
|
+
/** What one sender manages a month, and the limit that sets it. */
|
|
131
|
+
per_sender_per_month: number;
|
|
132
|
+
binding_limit: string;
|
|
133
|
+
capacity_per_month: number;
|
|
134
|
+
/** What a newly connected sender manages in its first month (email ramp). */
|
|
135
|
+
first_month_capacity: number | null;
|
|
136
|
+
senders_needed: number;
|
|
137
|
+
hit: boolean;
|
|
138
|
+
note: string;
|
|
139
|
+
};
|
|
140
|
+
export type CostEstimateResult = {
|
|
141
|
+
lines: CostEstimateLine[];
|
|
142
|
+
totals: {
|
|
143
|
+
fixed_credits_per_month: number;
|
|
144
|
+
flexible_credits_per_month: number;
|
|
145
|
+
flexible_worst_case_credits_per_month: number;
|
|
146
|
+
credits_per_month: number;
|
|
147
|
+
worst_case_credits_per_month: number;
|
|
148
|
+
usd_seats_per_month: number;
|
|
149
|
+
unpriced_lines: string[];
|
|
150
|
+
};
|
|
151
|
+
plan_fit: {
|
|
152
|
+
expected: CostEstimatePlanOutcome;
|
|
153
|
+
worst_case: CostEstimatePlanOutcome;
|
|
154
|
+
} | null;
|
|
155
|
+
recommended_plan: {
|
|
156
|
+
expected: CostEstimatePlanOutcome;
|
|
157
|
+
worst_case: CostEstimatePlanOutcome;
|
|
158
|
+
} | null;
|
|
159
|
+
limits: CostEstimateLimit[];
|
|
160
|
+
limits_hit: CostEstimateLimit["key"][];
|
|
161
|
+
assumptions: string[];
|
|
162
|
+
warnings: string[];
|
|
163
|
+
summary: string;
|
|
164
|
+
};
|
|
165
|
+
/** Clamp a scenario into the accepted bounds: whole, non-negative numbers. */
|
|
166
|
+
export declare function normalizeCostEstimateScenario(scenario: CostEstimateScenario): CostEstimateScenario;
|
|
167
|
+
export declare function estimateScenarioCost(rawScenario: CostEstimateScenario, sheet: CostEstimatePriceSheet): CostEstimateResult;
|