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.
When one provider input is an object, compose it from row columns inside a
literal. Ordinary values stay literal; an explicit nested column ref is resolved
for each row, including an optional path into a JSON cell:
{
"properties": {
"type": "literal",
"value": {
"email": { "type": "column", "columnKey": "email" },
"firstname": { "type": "column", "columnKey": "first_name" },
"lead_source": "OXYGEN"
}
}
}The equivalent template form accepts the same object/array shape with values
such as "{{email}}"; an exact placeholder preserves the cell's scalar or JSON
type instead of converting it to text.
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.