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
| Kind | Produces | Cost | Determinism |
|---|---|---|---|
manual | Imported, pasted, or entered value | 0 | Static |
source | Provider-sourced or imported fact | 0 or provider-defined | Source-defined |
formula | Computed from other cells in the row | 0 | Deterministic |
research | Live web search + LLM answer, per row | Model tier + one search per row | Non-deterministic, evidence-bound |
ai | LLM call, no web access | Credits per call | Non-deterministic |
tool | Provider tool call (catalog) | Varies | Provider-defined |
enrichment | Managed provider waterfall for one identity intent: work email, mobile phone, LinkedIn URL, or email verification | Per provider attempt | Provider-defined |
relation | Durable links to rows in any other Table; plain tables stay outside CRM (Relations) | 0 | Deterministic |
bind | Resolves each row to a CRM Record by identity matching | 0 | Deterministic |
lookup | Current value pulled from another table by matching a key (first match, count, exists, or aggregate) | 0 | Dynamic read + materialized runs |
workflow | Workflow-owned status or output | Depends on workflow | Workflow-defined |
system | Oxygen-managed metadata | 0 | System-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_type | Holds |
|---|---|
text | Strings |
numeric | Numbers |
boolean | True/false |
jsonb | Structured JSON — the default for AI and tool outputs |
timestamptz | Timestamps 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 istext+ a select semantic type. - Kind (table above) is the column's behavior —
relationis a kind, not a data type. Relation columns are never added withcolumns add;oxygen tables link --tocreates one for you, oroxygen tables relatedefines 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> --jsonManual
oxygen columns add <table-id> --label "Notes" --key notes --kind manual --data-type text --jsonFormula
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\")"}' \
--jsonUse 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?" \
--jsonThere 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 \
--jsonBy 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." \
--jsonReference 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"}}}' \
--jsonOne 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 \
--jsonThe 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 elementThree 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 ininputMapping— actually delivers the cell. - A JSON cell interpolates as JSON.
{{page_content}}on ajsonbcolumn 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, withinvalid_prompt_columnand the near-miss keys. The web editor saves anyway — it autosaves while you type — but paints the chip red andcolumns runrefuses 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}}." \
--jsonRow 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 --jsonThen 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 --jsonrerun 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 --jsonupdate 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 --jsonA 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 --jsonDiscarding cached cells is specific to lookup: on a manual or source column, oxygen columns retype coerces every stored value instead of dropping it.
Related
- Cells — per-cell state model.
- Relations — relation columns and CRM relationships.
- Waterfalls — provider fallback for common enrichment jobs.