OXYGENOxygen/ Docs
Data

Columns

Typed actions that produce row-level values, state, and provenance.

A column is a typed action on every row of a table. Some columns hold source data; others compute values, call AI, call provider tools, enrich records, or link workflow state. Every executable column writes cells with status, cost, provenance, and history.

Column kinds

KindProducesCostDeterminism
manualImported, pasted, or entered value0Static
sourceProvider-sourced or imported fact0 or provider-definedSource-defined
formulaComputed from other cells in the row0Deterministic
researchLive web search + LLM answer, per rowModel tier + one search per rowNon-deterministic, evidence-bound
aiLLM call, no web accessCredits per callNon-deterministic
toolProvider tool call (catalog)VariesProvider-defined
enrichmentManaged provider waterfall for one identity intent: work email, mobile phone, LinkedIn URL, or email verificationPer provider attemptProvider-defined
relationDurable links to rows in any other Table; plain tables stay outside CRM (Relations)0Deterministic
bindResolves each row to a CRM Record by identity matching0Deterministic
lookupCurrent value pulled from another table by matching a key (first match, count, exists, or aggregate)0Dynamic read + materialized runs
workflowWorkflow-owned status or outputDepends on workflowWorkflow-defined
systemOxygen-managed metadata0System-defined

Lookup vs. relation. A relation is a durable, two-way row link; its definition and edges stay in Tables unless it fills a relationship explicitly defined in CRM. A lookup is a computed join by a normalized value such as id, email, or domain. Use a relation for “these two rows are connected”; use a lookup for “show the current company attributes for this contact's domain.”

Lookup values are read-through and current, and filters and sorts resolve live via a join against the source rows. Past a row-count cap the source table is too large to join per-page without breaking cursor pagination, so filtering and sorting fall back to the last materialized cell — run the column to refresh that materialization. If current cross-table ordering is the core data model, use a relation and store the sortable fact on the table that owns it.

Data types

--data-type accepts exactly five storage types:

data_typeHolds
textStrings
numericNumbers
booleanTrue/false
jsonbStructured JSON — the default for AI and tool outputs
timestamptzTimestamps with timezone

Richer column behavior is layered on top of these, not new data types:

  • Semantic type (semantic_type) refines how a value is rendered and filtered: email, phone, url, domain, markdown / rich_text, single_select, multi_select, status, date, and similar. A select column is text + a select semantic type.
  • Kind (table above) is the column's behavior — relation is a kind, not a data type. Relation columns are never added with columns add; oxygen tables link --to creates one for you, or oxygen tables relate defines one by hand. See Relations.

columns retype converts a manual or source column to a different storage type, coercing every stored value in one transaction — the escape hatch for a column imported as text that should have been timestamptz. Both the current and target type must be one of text, numeric, boolean, jsonb, timestamptz; every other kind is refused with column_not_retypeable. Every non-null value must coerce or the whole retype is rejected with column_retype_failed and a sample of the offending rows, so a table is never left half-converted. --dry-run shows the row counts and samples first.

Adding a column

oxygen columns add <table-id> --label <label> --key <key> --kind <kind> --data-type <type> --json

Manual

oxygen columns add <table-id> --label "Notes" --key notes --kind manual --data-type text --json

Formula

oxygen columns add <table-id> \
  --label "Fit Score Number" \
  --key fit_score_number \
  --kind formula \
  --data-type numeric \
  --definition-json '{"expression":"path(fit_score, \"score\")"}' \
  --json

Use formulas to extract scalar fields from JSONB AI or tool outputs before downstream steps use them.

Research

A research column answers one question per row from the live web — what a company sells, whether they raised, which tools they run. Anything you would otherwise look up by hand belongs here rather than in an ai column.

oxygen columns add <table-id> --kind research \
  --label "What they sell" \
  --prompt "What does {{company_name}} ({{company_website}}) sell, and to whom?" \
  --json

There is no model or search engine to choose. Oxygen runs the search itself, feeds the retrieved pages to the model, and writes a fixed shape:

{
  "answer": "Payroll and compliance software for European SMBs",
  "found": true,
  "confidence": "high",
  "sources": [{ "title": "Acme — Pricing", "url": "https://acme.example/pricing", "engine": "exa" }]
}

sources comes from the pages Oxygen actually fetched, not from the model: the model cites evidence by position and Oxygen resolves it, so a research cell cannot invent a citation. Pull the answer out with a formula — path(what_they_sell, "answer") — or filter on found.

Research columns fail closed. If a row has no findable evidence the cell errors with grounding_unavailable instead of guessing, and only the searches that ran are billed — the model is never called. That usually means the search query needs help:

oxygen columns add <table-id> --kind research \
  --label "Funding stage" \
  --prompt "What funding stage is {{company_name}} at?" \
  --research-query "{{company_name}} funding round raised" \
  --research-domains techcrunch.com,crunchbase.com \
  --json

By default the model may summarize and combine what the sources say, while never asserting anything they do not support. --research-mode strict forces verbatim-only answers (use it when you need a figure or identifier quoted exactly); --research-mode estimate lets the model reason to a number from the evidence, for columns whose job is an estimate. --research-results <n> (1–25) sets how many search results to ground each row on; --reasoning-level low|medium|high spends more on the answer itself.

All of these stay editable in the Web research section of the column editor in the app — query, mode, result count, and the domains to include or exclude.

Cost is the model tier plus one search per row. Dry-run first — it is free and prints the exact estimate.

AI

An AI column has no web access. It reasons over the row's existing values. If a prompt asks it to check a website or recall a fact about a company, it will answer from the row and can fabricate — use a research column instead, or add webSearch: {"enabled": true} to the definition.

oxygen columns add <table-id> \
  --label "ICP Fit" \
  --prompt "Score {{company_name}} ({{domain}}) against our ICP. Return tier and one-sentence reason." \
  --json

Reference other columns in the prompt as {{column_key}} — that is the whole authoring step. --kind ai and the data type are implied, and no input mapping is needed. For OXYGEN's curated copy templates, swap --prompt for --prompt-key <key> plus --input-mapping; that path materializes a maintained prompt and output schema instead of your own text.

AI outputs are stored as JSON envelopes. Use a formula column or columns materialize when downstream tools need one specific field.

Tool

oxygen columns add <table-id> \
  --label "Person Enrichment" \
  --key person_enrichment \
  --kind tool \
  --data-type jsonb \
  --definition-json '{"version":1,"mode":"native","toolId":"blitzapi.person_enrich","inputMapping":{"person_linkedin_url":{"type":"column","columnKey":"linkedin_url"}}}' \
  --json

One toolId, one column. Find it with oxygen tools search "<what you want>", then oxygen tools get <tool-id> --json for the input schema, availability, and current price; every provider in the catalog is listed in the provider reference. columns add writes the definition and never spends.

Lookup

oxygen columns add <table-id> \
  --label "Company" \
  --key company \
  --kind lookup \
  --data-type jsonb \
  --lookup-table <companies-table-id> \
  --lookup-match company_id=supabase_id \
  --lookup-normalize exact \
  --lookup-return company_name,domain \
  --json

--lookup-match <local>=<source> names the join: the local column on this table, then the column to match in the source table. --lookup-mode is first-match (default; pulls the --lookup-return columns), count, exists, or aggregate; --lookup-order <column:dir> breaks a first-match tie. --lookup-normalize is exact, lower-trim (default), email, domain, or linkedin. Lookups are free: reads show current source values, and a run materializes them in large, observable batches.

Enrichment helper

oxygen enrich-column preview <table-id> \
  --capability work_email \
  --linkedin-url-column linkedin_url \
  --company-domain-column domain \
  --limit 10 \
  --json

The helper creates or reuses an enrichment column and picks the provider waterfall. --capability is work_email, mobile_phone, linkedin_url, or verify_email (default mobile_phone); preview makes no provider call and spends nothing. The first three FIND a value the row is missing; verify_email GRADES an email it already has — see Email verification. See also Waterfalls.

Referencing other columns

Every column that reads another column uses one grammar: {{column_key}}. It works in an AI or research prompt, in a research --research-query, in a custom-HTTP URL or body, and in a tool column's template inputs. In the app, the + Column button beside each of those fields inserts one, and a reference renders as a chip you can click to open the column it points at.

{{domain}}                 # the whole cell
{{research.answer}}        # a field inside a JSON cell
{{research.sources.0.url}} # an array element

Three rules are worth knowing:

  • Naming a column in prose does nothing. "Look at the scraped page content" sends the model those words, not the value. Only a {{token}} — or an entry in inputMapping — actually delivers the cell.
  • A JSON cell interpolates as JSON. {{page_content}} on a jsonb column renders the serialized object, so a whole scraped payload can go straight into a prompt.
  • Unknown references are rejected before the write. oxygen columns add, columns update, tables create, and every MCP equivalent refuse a definition whose {{token}} names no column, with invalid_prompt_column and the near-miss keys. The web editor saves anyway — it autosaves while you type — but paints the chip red and columns run refuses the paid run until it resolves.

inputMapping is a second, narrower channel: named inputs delivered to the model alongside the prompt. Use it for the things a prompt cannot name on its own — literals and workspace context — and read them by the same {{name}} grammar:

oxygen columns update <table-id> icp_fit \
  --input-mapping '{"icp":{"type":"context_profile","path":"icp"}}' \
  --prompt "Score {{company_name}} against this ICP: {{icp}}." \
  --json

Row columns belong in the prompt. Putting one in inputMapping instead still works — the value reaches the model — but nothing links the two, so the app cannot show the dependency and a reader cannot tell which input the prompt meant. columns add and columns update warn when an input is never referenced.

Run conditions are the exception: --run-condition takes a formula over bare column keys (eu_israel = true), not a {{token}} template, because it is evaluated by the formula engine rather than interpolated into text.

oxygen columns deps <table-id> prints the whole graph — what each column reads, what reads it, and any reference that resolves to nothing.

Renaming a column does not rewrite the references pointing at it. columns rename warns which columns read the old key; update them, or they fail on their next run.

Running a column

Dry-run first. It spends nothing and prints the resolved model, the credit estimate, the run-condition posture, and — for an AI column — the prompt rendered against one real row:

oxygen columns run <table-id> <column-key> --limit 1 --dry-run --json

Then run a small pilot, then the batch. A paid column always runs durably in the background and needs --approved plus a --max-credits ceiling:

oxygen columns run <table-id> <column-key> --limit 1 --approved --max-credits 10 --json
oxygen table-runs create <table-id> --column <column-key> --limit 100 --approved --max-credits 50 --json

--limit runs the next N rows whose target cell is still empty (--force runs the first N regardless); it defaults to 10, and inline deterministic runs cap at 25. --all requires --background.

See Runs, Modes, Approvals, and pricing reference for what each kind costs.

Maintenance

rename, reorder, archive, delete, restore, retype, deps, materialize, update, and rerun are the rest of the group — see the CLI reference. Three of them have sharp edges.

materialize copies JSON paths out of a result column. The fixed work_email preset creates email, email_provider, email_status, and email_enriched_at; it never binds to an existing column named work_email. Use --mappings-json when you need to write into a specific column.

oxygen columns materialize <table-id> person_enrichment --preset work_email --json

rerun is the narrow paid AI-only path for one cell. --dry-run first, then a required --max-credits; --from-review-id threads a message review's feedback into the regeneration prompt.

oxygen columns rerun --table <table-id> --column <column-key> --row <row-id> --dry-run --json
oxygen columns rerun --table <table-id> --column <column-key> --row <row-id> --max-credits 10 --json

update merges, it does not replace. --definition-json shallow-merges, so it can add or overwrite keys but never remove one. Name the keys to remove with --definition-unset — everything you do not name is left as it is, including keys another surface wrote since you last read the column:

oxygen columns update <table-id> <column-key> \
  --definition-json '{"prompt":"Qualify ICP fit","credentialMode":"hosted"}' \
  --definition-unset model,modelId --json

A lookup column's output type has to match its mode (count/aggregate need numeric, exists needs boolean, multi-column first_match needs jsonb). Change the mode and the type in one request — the cached cells are discarded and re-resolved on the next read:

oxygen columns update <table-id> open_invoices \
  --definition-json '{"sourceTable":"invoices","match":{"sourceColumn":"company_id"},"inputMapping":{"match_value":{"type":"column","columnKey":"company_id"}},"mode":"count"}' \
  --data-type numeric --json

Discarding cached cells is specific to lookup: on a manual or source column, oxygen columns retype coerces every stored value instead of dropping it.

  • Cells — per-cell state model.
  • Relations — relation columns and CRM relationships.
  • Waterfalls — provider fallback for common enrichment jobs.

On this page