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 outbound journey: LinkedIn, email, WhatsApp, call, and CRM-task steps dispatched with rate limits, reply-stop, and a unibox. Enrollment puts leads into one. 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 — Import leads | Getting rows into the sequence's leads table from a file, another table, or a HubSpot list; the direct paths auto-enroll afterwards |
CLI/MCP — --from-table / from_table: true | The 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 — the Leads stage
The Leads stage has one button, Import leads. It opens a source chooser with three cards: Upload a file (CSV, JSON, JSONL, or XLSX), Copy from a table (rows from another workspace table; disabled until a leads table is bound), and HubSpot list (keeps a saved list synced through a hosted DNC-first workflow). None of them is an enroll button: the two direct paths wait for their durable ingestion run to reach a terminal state, then enroll the sequence's bound table on your behalf, paging until nothing is left. A toast reports the enrolled count and, for a LinkedIn sequence, how many were skipped (no LinkedIn URL). A sequence with no bound table yet gets one created and bound by the import. 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 authorized (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> --max-live-sends <n> --json--approved is the only flag that makes a start live; without it you get a preview with rendered recipient samples, character counts, copy blockers, sender/mailbox capacity, and CRM readiness.
The two ceilings bound different things. --max-credits caps the LinkedIn track and is optional — omit it and the LinkedIn track runs unbounded. --max-live-sends caps total real external actions and is required whenever the sequence has email, WhatsApp, or crm_task steps, because those actions cost 0 Oxygen credits and a credit cap cannot bound them. Its effective value must also exceed the sequence's live_sends_used, or the start is refused.
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 rules --json # is it armed?
oxygen crm automation set crm-lead-stage-router --disarmed --live # turn it offcrm automation set defaults to dry-run; --live is what writes the change. Turning it off is a decision Oxygen remembers: a disarmed automation is never switched back on automatically.
Want different behaviour — 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 an existing 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: generic action/base-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 costs a stats pass per sequence — 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. An event that preserved the campaign but not the sending identity stays under unattributed rather than being assigned 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.