Sequence enrollment
Get leads into a sequence — from a bound table, an explicit list, or raw email/phone — before anything sends.
A sequence is Oxygen's native outbound journey: LinkedIn, email, and WhatsApp steps dispatched with rate limits, reply-stop, and a unibox. Enrollment is how leads get into a sequence — it is always free and never sends anything. Only starting the sequence dispatches real actions, behind its own approval gate.
Prerequisites
| Need | Required for |
|---|---|
A sequence, usually created bound to a source table (sequences create --table <table-id>) | Every enrollment path — --from-table specifically requires the bound table |
An attached sender, connected and active (oxygen senders list --json) | Starting LinkedIn or WhatsApp steps live |
Sendable mailboxes in the pool (oxygen mailboxes list --json) | Starting an email step live |
Enrollment itself doesn't touch a provider or spend anything — it only needs the sequence, and for --from-table, its bound table, to exist. Sender and mailbox health matter once you start the sequence live, not before.
Connect a sender with oxygen senders connect --country DE — use the account owner's normal LinkedIn login country, not the campaign target. It mints a shareable hosted-auth link (valid 10 minutes, one account per link) you can open yourself or send to the account owner; they sign in to their own LinkedIn and need no Oxygen login. The connected account appears under Connections and in oxygen senders list.
Three enrollment paths
| Path | Use for |
|---|---|
| Web — Enroll table rows button | A sequence with a bound source table; on the sequence's Leads stage |
CLI/MCP — --from-table / from_table: true | The same bound-table path, scriptable and re-runnable |
CLI/MCP — explicit leads (--leads-file / leads) | A list outside the bound table, or raw email/phone with no table at all |
Web — Enroll table rows
On a sequence's Leads stage, when the sequence has a bound source table, click Enroll table rows. It enrolls every row of that table not already in the sequence, up to 500 rows, and the confirm dialog reports Enrolled, Skipped, and — for a LinkedIn sequence — No LinkedIn URL counts. Leads land as pending; nothing sends until you start the sequence.
CLI/MCP — bound table (--from-table)
The one-command path for a sequence already bound to a source table:
oxygen sequences enroll <sequence> --from-table --jsonIn MCP, call oxygen_sequences_enroll with from_table: true instead of a leads array.
The response's from_table block reports the batch:
| Field | Meaning |
|---|---|
table_id, table_slug | The bound source table |
scanned_rows | Rows scanned this call |
already_enrolled_rows | Scanned rows skipped because they're already enrolled |
candidate_rows | Rows enrolled this call |
has_more | true if the table has more not-yet-enrolled rows |
At most 500 rows enroll per call. When has_more is true, run the identical command again — already-enrolled rows are skipped automatically, so the enrolled set is its own cursor; there's no offset or page token to track. Once every row is enrolled, re-running returns enrolled: 0 with the note "All table rows are already enrolled in this sequence." — success, not an error.
A sequence with no bound source table fails with sequence_no_source_table. Create the sequence with --table <table-id>, or use an explicit leads list instead.
CLI/MCP — explicit leads
For leads outside the bound table, or a sequence with no table at all:
oxygen sequences enroll <sequence> --leads-file leads.json --json{
"leads": [
{ "lead_name": "Ada Lovelace", "row_values": { "email": "ada@acme.com", "first_name": "Ada" } },
{ "lead_profile_url": "https://www.linkedin.com/in/example" }
]
}MCP's oxygen_sequences_enroll accepts the same list under leads. Each entry identifies its lead one of these ways:
| Identity | Resolves to |
|---|---|
table_row_id | An existing row; auto-snapshots that row's columns (incl. AI/tool outputs) into row_values |
lead_provider_id (the LinkedIn ACo… id) | LinkedIn, pre-resolved |
lead_profile_url | LinkedIn, resolved to a member id at send time |
row_values.email / row_values.phone | Email/WhatsApp with no table required — auto-filed as a row in the sequence's leads table (auto-creating and binding one if there is none), deduped by email/phone, returned as leads_table with a web_url |
LinkedIn URL resolution
A LinkedIn sequence no longer needs a pre-resolved provider id on every lead. Oxygen resolves the LinkedIn member id at send time from, in order: an explicit lead_provider_id, an explicit lead_profile_url, or the enrolled row's LinkedIn URL column — the sequence's configured linkedin_url_column_key, falling back to linkedin_url, linkedinUrl, linkedin, profile_url, or contact_linkedin.
Rows with no resolvable /in/ URL are not enrolled — they're dropped and counted in the enroll response's unresolved_linkedin_leads. Fix the cell (or point linkedin_url_column_key at the right column) and re-run --from-table; already-resolved rows are skipped, so only the fixed rows enroll.
Idempotency and skips
Enrollment is idempotent per (sequence, table_row_id) — re-running any enrollment path only adds what's new. Enrolling by email/phone with no table dedupes per email/phone instead.
Every enroll response reports skipped with a skipped_by_reason breakdown:
| Reason | Meaning |
|---|---|
already_enrolled_this_sequence | Already has an enrollment here — the normal idempotent re-run case |
already_enrolled_other_sequence | Only counted when --exclude-contacted opts in to cross-campaign exclusion |
on_suppress_list | Org do-not-contact list — always enforced |
on_request_suppress_list | This call's --suppress-list |
bound_to_other_sender | Already owned by a sender outside this sequence's pool — override with --ignore-sender-bindings only when cross-account contact is intended |
unresolved_linkedin_leads is reported separately from skipped_by_reason since it isn't an exclusion decision — the row just has nothing to dispatch to yet.
Nothing sends until you start
Enrollment — any path — never dispatches anything. Enrollments land pending; if the sequence is already active they activate for the next dispatch tick, but dispatch itself only fires for steps a live start has unlocked (see Modes for dry_run vs live).
oxygen sequences enrollments <sequence> --json # inspect enrollment status
oxygen sequences start <sequence> --json # preview — no sends
oxygen sequences start <sequence> --dry-run --json # simulate — no sends, no credits
oxygen sequences start <sequence> --approved --max-credits <n> --json # live — required for LinkedIn steps
oxygen sequences start <sequence> --approved --max-live-sends <n> --json # live — required for email steps--max-credits bounds the LinkedIn track; --max-live-sends bounds the email track (email is 0-credit, so a credit cap can't bound it). A sequence with both channels needs both flags on the same live start.
Replies land in your CRM
When someone replies, Oxygen files them as a person in your CRM and moves their lead stage:
| They replied | Lead stage becomes |
|---|---|
| Interested | engaged |
| Booked a meeting | meeting |
| A clear no | disqualified |
| Stop / unsubscribe | unsubscribed |
Auto-replies, wrong-person and bounced conversations are skipped — those aren't people to add. Stage moves are forward-only, so a reply can never move someone backwards out of a closed stage (a customer stays a customer).
This is one automation for the whole workspace, not a per-campaign setting. It switches on the first time you start a sequence live and then covers every sequence you create afterwards — you never set it up again.
oxygen crm automation --json # is it on?
oxygen sequences analytics --json # same answer, next to your funnel
oxygen crm automation set crm-lead-stage-router --disarmed --live # turn it offTurning it off is a decision Oxygen remembers: a disarmed automation is never switched back on automatically.
Want different behaviour — for example adding everyone you email rather than only the people who reply, or filing them in a nightly batch instead of instantly? That's a fork, not a setting; see Customizing a shipped automation.
Track performance and organize
oxygen sequences list --stats --json # lifetime funnel per sequence: enrolled, sent, replies, reply rate, email opens/clicks, opportunities
oxygen sequences analytics --range 30d --json # all campaigns + cross-campaign mailbox/domain attribution
oxygen sequences variants <sequence> # one campaign: variant, mailbox, domain, reply type, and bounce tables
oxygen sequences events <sequence> --json # per-lead activity with mailbox/domain provenance where known
oxygen sequences list --tag q3-outbound --json # only sequences carrying a workspace tag
oxygen sequences update <sequence> --tags q3-outbound,saas-founders # replaces the set; "" clears; works on archived sequences too--stats joins each row's lifetime SequenceStats (a stats pass per sequence — prefer it when you need the numbers, skip it for a quick name lookup). Tags link the sequence to its lead table, workflow, and campaign-learnings wiki page; the /sequencer web list shows the same stats as sortable columns with per-row start/pause/duplicate/archive actions.
Mailbox/domain attribution is built from Oxygen's durable send, conversation, reply-signal, and hard-bounce provenance. Replies retain their current classification, while a mailbox or domain can aggregate facts from several campaigns. If an older/provider event preserved the campaign but not the sending identity, the response keeps it under unattributed instead of assigning it to a likely inbox. Provider-owned campaigns do not enter this report merely because their integration is connected; their telemetry must first be normalized into Oxygen's native action/message ledgers.
Related
- Approvals, Spend caps — the gate a live
startenforces. - Modes —
dry_runvsliveforsequences start. - Tables — the bound source table
--from-tablereads. - Web app — the sequence's Leads stage and Enroll table rows button.