OXYGENOxygen/ Docs
Guides

Track deals in the CRM

Keep companies, people, and deals as canonical records — open them in the app, write them from the CLI, enrich them, merge duplicates, and promote table columns onto them.

The CRM holds one canonical row per company, person, and deal, each with identities, an owner, a stage, and a timeline.

Records are truth, Tables are work

A table builds and enriches a list: a TAM pull, a CSV, a scrape. A record is that entity deduped, addressed by identity — companies by domain, people by email, deals by deal_key. They connect one way: bind rows to records, then promote the columns worth keeping.

# 1. Resolve each row to a CRM record. Free. In the app: "Add column" → "Bind to CRM record".
oxygen columns add my-leads --label "CRM person" --bind-object people --bind-map email=work_email
oxygen columns run my-leads crm_person --limit 25

# 2. Preview which record fields would change. Free, writes nothing.
oxygen tables promote my-leads --object people --map job_title=job_title,seniority=seniority

# 3. The same command plus --approved writes them onto the records.

columns add prints the key it derived from the label — pass that to columns run. promote defaults to --policy fill-empty-only (also overwrite, skip-conflicts), never touches identity or system attributes, and queues a background run you follow with oxygen table-runs wait <run_id>.

Set up the objects

Sidebar Data → CRM; each object is a child: Companies, People, Deals, plus any custom object. Data model (/crm/objects) sits under them and is where the workspace's objects are listed, created and read: what each one records, which field makes two records the same, and how they link. An empty workspace lands on No CRM objects yet.

oxygen crm setup          # dry run: what it would create or repair
oxygen crm setup --live   # creates companies, people, deals

Most crm writes preview first and need --live: setup, assert, activity log|note, objects create|delete|add-attr|update-attr|remove-attr, relationships define|upsert, merge, automation set, and sync import|configure|run. crm tag, every enrichment verb, and sync enable|disable write on the spot. Check any command's own --help before running it.

Custom objects come from Data model → New object (/crm/objects/new) or oxygen crm objects create --slug projects --display-name Projects --columns-json '[…]' --live. oxygen crm objects lists what exists, and /crm/objects/<slug> shows one object's fields, identities and links.

Two slug groups are reserved. companies, people, deals, lists and activities belong to standard objects and the engines behind them; objects, automation, calls and new are fixed /crm/* routes, and an object named after one would be shadowed by the page of the same name.

Adding a field to an object you already have — including Companies, People and Deals — is Settings → CRM Data Model → the object → Add field, or oxygen crm objects add-attr <object> --column-json '{…}' --live. A field you add belongs to the workspace and survives later crm setup runs.

Changing a field is the same page: click its row to rename it, make it required or optional, change a select or status field's choices, or remove it. On the terminal that is oxygen crm objects update-attr <object> <field-key> --name "…" | --required | --no-required | --options-json '[…]' --live and oxygen crm objects remove-attr <object> <field-key> --live. The field's key never changes, so anything that reads it by key keeps working. A removed field's column is archived rather than dropped and its key is free again immediately; oxygen columns restore brings it back as the same field. oxygen crm describe <object> --field <key> reads one field on its own. What Oxygen ships on a standard object keeps its key, type and required flag and cannot be removed, but its name and its choices — the deal pipeline, the lead stages — are yours to shape.

An identity is what makes two records the same one — it is what stops a re-import creating a second copy of the same company. Each identity names a column plus how its value is compared: email_v1, domain_v1, linkedin_url_v1, exact_text_v1 or uuid_v1. These are not the short exact/email/domain spellings that --lookup-normalize takes on a Table column. At most one identity per object is primary.

A select or status column carries its choices at create time, in the column's own options array alongside option_model_type (status, single_select or multi_select) — not in a metadata block, which is part of what crm describe reports back rather than something create accepts:

{ "key": "status", "label": "Status", "data_type": "text", "semantic_type": "status",
  "option_model_type": "status",
  "options": [{ "value": "prospect", "label": "Prospect" }, { "value": "active", "label": "Active" }] }

Work a record

/crm/deals opens on a seeded board view named Pipeline, grouped by Stage: Lead → In Progress → Won / Lost. Drag a card to move the deal. The stage set is workspace-editable, so a team that wants a longer ladder adds its own.

Switch to the table view and a deal opens on six fields — Deal Name, Stage, Deal Owner, Associated People, Associated Company, Created at. Everything else it carries (amount, priority, close date, source, the buying committee, tags) is one click away in the column menu, and oxygen crm describe deals --json names the opening set as openingGrid so an agent can read it too.

Any cell opens the account view: the record's attributes on the left and, on the right, Activity, Inbox / Sequences where the object has them, Notes, Tasks and Files. The stage cell opens its picker in place instead.

Notes are the record's own notes — written in the same markdown editor as a markdown field, pinnable, editable, and mirrored onto the timeline. Tasks are the reminders filed on the record (a title, an optional owner, deadline and priority), grouped To do / Done. Files are what the customer sent you, attached to the record. Every one of them is free and internal: no preview, no approval. CRM → Tasks (/crm/tasks) is the same list across the whole workspace — every open reminder, which record it is about, its owner and deadline — with a Mine filter.

The CRM companies grid

From the CLI:

oxygen crm search "acme.com" --object companies
oxygen crm get companies <row_id> --json
oxygen crm assert companies --identity domain=acme.com --values-json '{"lifecycle_stage":"customer"}' --live
oxygen crm activity note companies <row_id> "Intro call booked" --live
oxygen crm activity log deals <row_id> --type meeting_booked --summary "Demo with CTO" --live
oxygen crm activity timeline companies acme.com
oxygen crm tag companies <row_id> --add tier-1
oxygen crm pipeline --pipeline sales
oxygen crm notes add companies <row_id> "They want a pilot with 3 seats; decision maker is the Head of RevOps"
oxygen crm tasks add companies <row_id> "Send the deck" --due 2026-09-25 --priority high --assignee [email protected]
oxygen crm tasks list                      # every open reminder in the workspace, with its record
oxygen crm tasks list --assignee me        # just yours
oxygen crm tasks complete <task_id>
oxygen crm files attach companies <row_id> ./proposal.pdf

search takes exactly one query — narrow it with --object, never a second argument. timeline accepts a row id or an identity value (acme.com, [email protected]); a miss returns crm_record_not_found naming what to pass instead. crm tag writes deltas (--add unions, --remove subtracts) against the same tag vocabulary as sequences and conversations, and needs the tags attribute crm setup adds.

A live crm assert fires the object's standing automatic enrichment on the record it wrote, spending credits up to that object's per-batch ceiling. Run oxygen crm enrichment list first to see what is armed.

Automatic enrichment (paid)

Every CRM grid's top bar carries the enrichment popover, headed New records added to Companies are enriched automatically. It lists each preset's per-record credit cost, the per-batch ceiling, and Backfill existing records with an Enrich … now button and Enrich all.

oxygen crm enrichment list --object companies                       # armed presets, cost per record, ceiling, how many records were never enriched. Free.
oxygen crm enrichment enable company_linkedin_profile --object companies
oxygen crm enrichment backfill --object companies                   # free preview: one batch's cost, and the cost to finish
oxygen crm enrichment backfill --object companies --limit 200 --live --approved --max-credits 2000

Arming covers only records written after it, so existing records stay blank until you backfill. crm setup arms the default presets on objects it creates; crm setup --with-enrichment arms them on objects that already existed. When your plan runs fewer columns per record than the defaults, setup arms the ones that fit and lists the rest under autoRunColumnsOverLimit. A live batch without --approved fails with approval_required (exit 7). Records past --max-credits are skipped with credit_limit_reached — the preview says how many. oxygen crm enrichment off --object companies clears the configuration entirely.

Merge duplicates

/crm/companies/duplicates (no sidebar entry yet — type the URL) ranks candidate pairs. Review merge opens the field-level diff, Confirm merge writes it.

oxygen crm duplicates companies --limit 25
oxygen crm merge companies <survivor_row_id> <loser_row_id>                     # dry run: field diff + relink plan
oxygen crm merge companies <survivor_row_id> <loser_row_id> --live --confirm

The survivor keeps its values, the loser fills its blanks and is tombstoned with its links moved over. --live without --confirm refuses: "crm merge --live requires --confirm after inspecting the dry-run field diff."

Standing automations

/crm/automation lists the templates that advance a record when a reply, meeting, or signup arrives — crm-lead-stage-router, crm-meeting-booked, crm-signup-to-person — each badged Armed or Disarmed with a toggle. The lead-stage router arms itself the first time a sequence goes live; once disarmed, nothing re-arms it but you.

oxygen crm automation rules
oxygen crm automation set crm-meeting-booked --armed --live --approved --max-credits 1   # drop --live to preview it first
oxygen crm automation audit --limit 20

An armed rule is a standing permission for free internal CRM writes; --max-credits is the ceiling per event delivery.

Two-way sync with HubSpot or Attio

Connect the provider under External → Connections, then:

oxygen crm sync import hubspot --object contacts --into people                             # dry run
oxygen crm sync configure --provider hubspot --object contacts --direction bidirectional   # dry run of the next cycle
oxygen crm sync enable --provider hubspot --object contacts --approved --max-rows 500 --max-credits 1000

sync enable is always live: it arms the cron trigger and the standing write permission, then runs a first cycle. Inbound previews and every cycle read through your own provider credentials, consuming your CRM's API quota. Details in Integrations.

Importing one HubSpot saved list instead of a whole object

crm sync import pulls every record in an object into Records. It has no list or segment filter. To bring one HubSpot saved list in as rows you can filter and enrich, import it into a table instead:

oxygen tables hubspot lists                                                   # the portal's saved lists; free
oxygen tables hubspot import --list <lead_list_id> --dnc-list <dnc_list_id>   # free preview, returns a fingerprint
oxygen tables hubspot import --list <lead_list_id> --dnc-list <dnc_list_id> \
  --live --approved --reviewed-fingerprint <hash>

Choose by destination: Records for canonical truth about every contact, a Table for one segment you want to work. The table path also imports a do-not-contact list — any list passed as --dnc-list is synchronised into your suppression ledger before a single row is written, and if that snapshot fails no row lands. Suppression is additive: removing someone from the HubSpot list never un-suppresses them in Oxygen. Importing with no DNC list is allowed but must be deliberate (--no-dnc). Oxygen suppresses from a saved list only — a HubSpot opt-out property such as hs_email_optout is not a supported source.

On this page