OXYGENOxygen/ Docs
Execution

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

NeedRequired 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. Bulk links (--count above 1) need --owner-consent. The connected account appears under Connections and in oxygen senders list.

Three enrollment paths

PathUse for
Web — Import leadsGetting 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: trueThe 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 --json

In MCP, call oxygen_sequences_enroll with from_table: true instead of a leads array.

The response's from_table block reports the batch:

FieldMeaning
table_id, table_slugThe bound source table
scanned_rowsRows scanned this call
already_enrolled_rowsScanned rows skipped because they're already enrolled
candidate_rowsRows enrolled this call
has_moretrue 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": "[email protected]", "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:

IdentityResolves to
table_row_idAn 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_urlLinkedIn, resolved to a member id at send time
row_values.email / row_values.phoneEmail/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, counted in the enroll response's unresolved_linkedin_leads, and listed in unresolved_linkedin_lead_details with a missing array naming everything the row lacks (linkedin_profile, and in a sequence with email steps also email or source_row). The exception is a sequence that also has email steps: a row with no profile but a recipient email is enrolled for those email steps, each of its LinkedIn steps is skipped (skipped_reason: no_linkedin_profile), and its connection waits and branches take the not-connected path. Those rows are listed in linkedin_unavailable_leads / linkedin_unavailable_lead_details instead, each with reason: no_linkedin_profile (why the LinkedIn value was unusable stays in linkedin_reason), and an email-only lead in --leads-file is auto-filed into the leads table as above. 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. That re-run does not upgrade a row already enrolled for email only: the profile is read at enrollment, so a URL added later does not add its LinkedIn steps. The reverse also holds: a lead with a profile but no recipient email skips each email_send step (skipped_reason: no_recipient_email). Rows that were not enrolled are not counted in the response's skipped, which covers only the skipped_by_reason buckets.

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:

ReasonMeaning
already_enrolled_this_sequenceAlready has an enrollment here — the normal idempotent re-run case
already_enrolled_other_sequenceOnly counted when --exclude-contacted opts in to cross-campaign exclusion
on_suppress_listOrg do-not-contact list — always enforced
on_request_suppress_listThis call's --suppress-list
bound_to_other_senderAlready 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.

Deleting a table row does not remove its lead

A lead keeps its own copy of the row it was enrolled from, so deleting that row from the table leaves the lead in the campaign and it keeps receiving steps. The row delete names the campaigns still enrolling it, sequences get, sequences list, the launch preview and the campaign's Contacts view count such leads, and sequences enrollments marks each one missing_source_row: true. To remove them, preview (it names the leads), then apply:

oxygen sequences contacts-remove <sequence> --missing-source-rows --json             # preview
oxygen sequences contacts-remove <sequence> --missing-source-rows --approved --json  # remove up to 500 per call

One call covers the oldest 500; the preview says when more remain, so repeat after applying. MCP: oxygen_sequences_contacts_remove with missing_source_rows=true. Web: Remove from campaign on the notice above the leads grid, on the Leads step of a draft and in the Contacts view once launched.

A lead counts only when OXYGEN recorded its row being deleted from the campaign's own source table. A lead enrolled from another table's row is never counted: the row delete lists those row ids instead, and Remove from campaign on the web delete message, or --table-row-ids, removes them.

Deleting the whole source table leaves its enrolled leads in the campaign too. sequences get, sequences list, the launch preview and the campaign page warn about it (sequence_source_table_deleted): pause the campaign to stop sending, or restore the table (oxygen tables restore <table>, or Restore table on the campaign page) to review and remove leads.

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 and channels
oxygen sequences preview <sequence> --json                                # preview — no sends, no writes, 0 credits
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 LinkedIn/WhatsApp recipient samples, character counts, copy blockers, sender/mailbox capacity, and CRM readiness. Email copy is not rendered; inspect the saved email templates and unsubscribe settings with sequences get. sequences preview returns that same preview as a read-only command, and MCP clients reach it through oxygen_capabilities_read on oxygen_sequences_start with approved: false. Each enrollment's channels lists the channels that reach the lead (usable) and the ones that cannot, with a reason: no_linkedin_profile marks a lead enrolled for the email steps only. A --dry-run start walks the enrolled leads through every step in simulation, and a later --approved start restarts those leads from the first step so they are contacted for real, except a lead whose source row was deleted, a person another live campaign contacts under exclude_contacted, and older duplicates of a person.

With several selected senders (sender profiles), each new lead gets one person at enrollment (whoever has the fewest of the campaign's leads, unless the lead already talks to one of them on LinkedIn), and its LinkedIn and email steps come from that person: LinkedIn from their account, email from one of their inboxes (kept for every default step). A contacted lead waits for its person's account or inbox to recover instead of switching sender. Per step: an email_send step's sender_profile_id sends that email from any inbox of one selected person (each lead keeps the same one of that person's inboxes for every step naming them), and its sender_mailbox_id from one exact inbox; set one or neither. A different From starts a new thread that needs its own subject. A LinkedIn step's sender_profile_id sends it from one selected person. LinkedIn lets one of your accounts hold a conversation with a lead, so the connection, message, InMail and withdraw steps share one sender_profile_id or none; a shared one makes that person the owner of every lead's LinkedIn thread while email still rotates. Profile visits, follows, likes and comments may each name anyone selected. The start preview's sender_journey lists every email and LinkedIn step's sender, with the candidates a lead can be assigned. Launch blocks a selected person who cannot carry a channel the journey uses, LinkedIn steps that name different people (a draft still saves them, with a sequence_linkedin_owner_conflict warning, and sender_journey shows those steps as written, marked blocked), and an unavailable or unselected step sender. Campaign analytics attribute sends and replies to the actual inbox and step.

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.

Email defaults and health

New native email sequences use a 25/day campaign cap per mailbox, at least 12 minutes between sends, and Monday–Friday 09:00–17:00 UTC when no sending schedule is set, which the create result states. Sends are spread across that window. Mailbox caps, actual-send ramp and provider limits can reduce the effective allowance. All settings remain configurable in Sending settings, the schedule editor, CLI or MCP.

Open and click tracking default to off. Turning them on also requires a verified tracking domain and deployment configuration. A journey that waits for opens/clicks or selects variants using those signals cannot start while the required tracking is off. New messages use plain text; Oxygen does not automatically insert images.

Bounce protection warns above 2% and can pause above 3%, with at least 20 accepted sends in a rolling seven-day window. Thresholds, minimum volume, window and auto-pause remain configurable per sequence or mailbox. Hard-bounce facts are retained independently of suppression choices; existing do-not-contact entries stay enforced.

oxygen sequences update <sequence> --email-min-gap-minutes 12 --json
oxygen sequences update <sequence> --bounce-protection '{"enabled":true,"warning_rate":0.02,"pause_rate":0.03,"min_sends":20,"window_days":7}' --json

Launch readiness reports recipient-verification status, combined known warm-up/campaign volume and missing evidence. It never buys verification. A successful health poll with no measurement remains unknown, with its reason and separate poll/evidence timestamps; neither provider acceptance nor warm-up activity proves inbox placement.

Sending schedule

One schedule covers every channel in the journey: the Launch stage's Sending schedule, --schedule-* on the CLI, sending_schedule on MCP. It saves the same timezone, days and hours to the email window and the LinkedIn/WhatsApp window.

oxygen sequences update <sequence> --schedule-timezone Europe/Berlin --schedule-days mon-fri \
  --schedule-hours-start 09:00 --schedule-hours-end 17:00 --json

An email step's own send_window overrides the schedule for that step. LinkedIn sends also stay within each LinkedIn account's working hours (default 07:00–22:00 in the account's timezone), which you set with oxygen senders limits set, and are spread across them: an account's 20 daily invites go out 30–45 minutes apart, starting at a different time each day. WhatsApp accounts and email mailboxes have no hours of their own, so for them the schedule (and, for email, a step's send_window) is the only hours limit. Email set to send in each recipient's own timezone keeps doing so; the schedule's zone then covers leads without one. Creating a sequence takes only this one schedule, as Launch does. To keep one channel on different hours on purpose, or to send email in each recipient's own timezone, set the per-channel window afterwards with oxygen sequences update (--send-window-file, --linkedin-*; MCP email_send_window, linkedin_schedule). oxygen sequences get names each channel's window and where it is stored under sending_schedule. When a mixed journey's two windows differ, oxygen sequences list, oxygen sequences get and the launch check warn with sequence_schedule_windows_diverged.

Unsubscribe option

One option adds unsubscribe to emails: the Launch switch, --include-unsubscribe on the CLI, include_unsubscribe on MCP. It turns on a visible link and one-click headers together. oxygen sequences update can still set either part alone (--include-unsubscribe-link, --include-unsubscribe-headers). Write any personal reply-to-stop wording into the email copy itself; there is no separate opt-out text setting. Existing authored copy is not rewritten by the defaults cutover.

oxygen sequences update <sequence> --include-unsubscribe --json
oxygen sequences update <sequence> --no-include-unsubscribe --json

Both parts go on future first-touch cold emails; in-thread follow-ups and replies carry neither. Plain-text links show the full URL; HTML links use Unsubscribe. Link scanners opening a URL do not unsubscribe: the signed confirmation requires POST. Disabling presentation does not remove existing do-not-contact entries. Google, Microsoft Graph and SMTP support the headers. Zapbox sends (mailboxes imported from Zapmail, shown as transport: zapbox in oxygen mailboxes list --json) keep the visible link when both options are enabled, but cannot add one-click headers; a headers-only Zapbox request is refused.

For qualifying bulk marketing traffic to personal Gmail accounts, Google requires both one-click unsubscribe headers and a visible unsubscribe link. Optional controls alone do not establish compliance. See Google's sender guidelines.

Replies land in your CRM

When someone replies, Oxygen files them as a person in your CRM and moves their lead stage:

They repliedLead stage becomes
Interestedengaged
Booked a meetingmeeting
A clear nodisqualified
Stop / unsubscribeunsubscribed

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 off

crm 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.

Pause a company when someone replies

A reply always stops that lead. A campaign can also pause the replier's colleagues: everyone at the same company (same work-email domain, never a freemail one, or the same company domain or LinkedIn company field) stops receiving messages until you resume the company, then continues where they left off. Choose which reply categories pause the company in Launch → Reply handling, or:

Oxygen detects the work-email domain from each enrolled lead automatically. You do not enter a domain for reply-triggered pauses. New sequences leave this setting off until you enable it. If a replier has no work-email domain or explicit company identity, their own outreach stops and no company hold is created; the skipped hold is logged as no_company_identity.

oxygen sequences update <sequence> --company-reply-pause default --json   # interested, neutral, not_now, not_interested
oxygen sequences update <sequence> --company-reply-pause off --json
oxygen sequences get <sequence> --json   # inspect settings.company_reply_pause

The company is held as soon as a human reply arrives, and the pause stays or lifts once the reply is classified. The same pause is an action you can run yourself or from a workflow, for example when a meeting is booked in your CRM. It covers every campaign unless you name one:

oxygen sequences pause-company --domain acme.com --dry-run --json
oxygen sequences pause-company --domain acme.com --json
oxygen sequences paused-companies --json
oxygen sequences resume-company --domain acme.com --json

In the app, Sequencer → Do not contact → Paused companies lists and resumes them. A pause spends nothing and can always be resumed. Emails already handed to a bound Instantly campaign keep sending from Instantly, and a pause that reaches such a campaign warns you so. A company do-not-contact entry is different: it blocks the company in every campaign, permanently.

Recovering terminal failures proven to have no effect

Current provider-capacity pressure is a deferral, not a terminal failure: Oxygen records the reason as provider_capacity, waits without sending, and retries when capacity returns. Historical Sequence actions created before that deferral path can instead be left terminal even though the provider call definitively never happened. The same recovery seam also recognizes narrowly allowlisted Oxygen request-format defects when the provider's validation response proves the action was not applied—for example, the historical WhatsApp cold-chat request that sent bare digits instead of a provider-ready JID.

Preview the exact legacy scope for one Sequence:

oxygen sequences recover-capacity <sequence> --json

The preview is read-only, costs 0 credits, makes 0 provider calls, and writes nothing externally. scanned_count is the number of terminal candidates inspected; eligible_count is the exact safe recovery scope; excluded_by_reason explains everything refused. A zero scanned_count across a Sequence means there is no candidate for this recovery path—it does not mean failures are unmeasured.

Sequence action errors use the canonical failure_class values capacity, recipient, authorization_scope, funding, validation, provider, internal, effect_unknown, and unknown. provider_capacity is a deferral reason, while provider_capacity_deferred is its error code; neither is a separate failure class. Recovery accepts a terminal capacity failure only with definitive no-effect evidence, and a non-capacity failure only when its exact historical signature is explicitly allowlisted as an Oxygen request defect with a provider-confirmed no-effect outcome. It refuses ambiguous effects, ordinary recipient failures, later delivery evidence, suppression, and any changed execution scope.

Applying recovery is deliberately separate from preview. Pause the Sequence, inspect the returned scope and exclusions, then use the preview-bound approval token only if that exact local repair is intended. Applying restores local action/enrollment state; it never sends, calls a provider, spends credits, or resumes dispatch, and the Sequence remains paused.

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/delete 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.

Calls and phone numbers

A call_task step puts the lead in the call queue for a person to dial. The call itself happens in the web softphone at /sequencer/calls, a beta feature you turn on under Settings → Beta features. The CLI and MCP never dial. They work the queue and the phone numbers around the call.

ActionWebCLIMCP
Place a callSoftphone (/sequencer/calls)——
Work the call queueSoftphonevoice tasks list|queue|claim|complete|extend|skip|overrideoxygen_voice_tasks_list, oxygen_voice_tasks_update
List numbers, daily cap, dials left todaySettings → Billing & creditsvoice numbers listoxygen_voice_numbers
Search numbers, country requirements (free)—voice numbers search, voice numbers requirementsoxygen_voice_numbers action=search, action=requirements
Buy numbers—voice numbers buyoxygen_voice_numbers action=buy
Change a number's daily cap (1–300, default 100)Settings → Billing & creditsvoice numbers cap setoxygen_voice_numbers action=cap
Tag a number—voice numbers tagoxygen_voice_numbers action=tag
Release a numberSettings → Billing & creditsvoice numbers releaseoxygen_voice_numbers action=release
oxygen voice numbers search --area-code 415 --json
oxygen voice numbers buy --count 2 --area-code 415 --json            # preview: the numbers and the monthly credits
oxygen voice numbers buy --count 2 --area-code 415 --approved --json # buys; a recurring monthly charge until released
oxygen voice numbers cap set +14155550100 --daily-cap 150 --json

A number starts in warm-up and dials at its warm-up limit until it has warmed, even when its cap is higher. When a number's monthly credit reservation cannot be renewed, it stops dialing. voice numbers list then shows it with reservation and 0 dials left, and names the top-up link and the date it is released to the carrier under reservations_paused. Voice prices are in the pricing reference.

  • Approvals, Spend caps — the gate a live start enforces.
  • Runs — the adjacent waiting state for current provider-capacity pressure outside Sequences.
  • Modes — dry_run vs live for sequences start.
  • Tables — the bound source table --from-table reads.
  • Web app — the sequence's Leads stage.

On this page