Table Workspace
Applies to: ChengOS v0.1.0+ | Last verified: 2026-08-12 | Source:
crates/cheng-core/src/domain/table,crates/cheng-nodes/src/nodes/builtin/table,crates/cheng-api/src/rest/handlers/table/mod.rs,chengflow-ui/src/features/table
Tables are ChengOS's structured-data counterpart to documents: a multi-dimensional table in the Airtable sense — typed columns, ordered records, and saved views — rather than a spreadsheet of loose cells.
The difference matters for workflows. Because every column has a declared type and every record is addressed by a stable id, a model can be handed a table and asked to modify it, and the result is still validated data rather than free text.
The domain model
Table (aggregate root)
├── Column
│ └── ColumnConfig (e.g. select options)
├── Record
│ └── CellValue (tagged union)
└── View
└── ViewConfig
Column types — text, number, select, date, checkbox.
Cell values are a tagged union, not bare JSON: null, text, number, checkbox,
select, date. The tag is carried in the wire format, so a cell is written as
{"type": "text", "value": "hello"}. Validation happens on the way in — numbers reject NaN and
infinity and are capped at 10 decimal places, dates must be YYYY-MM-DD, and a value whose type
does not match its column is rejected rather than coerced.
null is the universal clear: writing it empties a cell of any type.
Ordering uses a fractional order_index on both columns and records, which is why inserting
a record between two others does not renumber the whole table.
Views
A view is a saved configuration over the same records, not a copy of them. ViewType declares
grid, kanban, calendar, gallery, and timeline; only grid is implemented today —
the rest are placeholders in the enum, and choosing one gets you nothing yet.
The grid view supports merged cells in both the header and the data region. Data-region merges are anchored to record ids, not row indices, so a merge survives sorting and filtering instead of smearing across whatever rows happen to occupy those positions.
Querying
Records are queried with a filter tree, not a flat condition list:
FilterNode
├── Condition { column_id, operator, value }
├── And([FilterNode, ...])
└── Or([FilterNode, ...])
Operators — equals, not_equals, contains (text only), gt, gte, lt, lte
(numbers and dates), is_empty, is_not_empty. The last two take no value.
Sorting is a SortSpec of column plus asc/desc.
The table nodes
Three nodes, each with a distinct job.
| Node | What it does |
|---|---|
table/query | Reads columns and records, paginated |
table/apply_record_ops | Applies a batch of insert / update / delete operations |
table/build_llm_context | Packages a table plus an instruction into a model-ready prompt |
table/query returns column definitions (include_schema, default true) alongside the
records, because a downstream model cannot interpret data without knowing what each column id
means. Pagination defaults to 100 records per page with a hard cap of 1000; pass record_ids to
fetch a specific set instead. workspace_id is injected from the ambient execution context and
used to validate that the table actually belongs to the caller's workspace.
Writing records
table/apply_record_ops takes an ordered operations array, each tagged by op:
{
"table_id": "...",
"operations": [
{ "op": "insert", "data": { "col_1": { "type": "text", "value": "Acme" } } },
{ "op": "insert", "data": { "col_1": { "type": "text", "value": "Globex" } },
"position": { "after": { "reference_id": "<record-id>" } } },
{ "op": "update", "id": "<record-id>", "data": { "col_2": { "type": "number", "value": 42 } } },
{ "op": "delete", "id": "<record-id>" }
],
"summary": "import from CRM"
}
Two details that change how you use it:
updateis PATCH, not PUT. Only the columns present indataare touched; omitted columns keep their values. You never have to read a record before updating one field of it.insertdefaults to append. Passpositionwith anafterreference id to place a record precisely.
The output reports per-operation results plus records_inserted / records_updated /
records_deleted / records_affected, so a partially successful batch is diagnosable without
re-reading the table.
summary is optional and exists for logging and audit — use it when an agent is the caller.
Handing a table to an agent
table/build_llm_context does the packaging that would otherwise be prompt-engineering
scattered across your workflow. Given a table_id and an instruction, it emits:
system_prompt— the rules for editing this tableuser_prompt— the instruction plus the rendered tabletools_json— function-calling tool definitions for the record operationstable_id— carried through for the downstream node
Options: format (json, markdown, or csv; default json), max_records (default 100,
0 = unlimited), and selected_record_ids to restrict the context to a subset — the natural
choice when the user has selected rows in the grid.
The canonical shape is a three-node chain:
table/build_llm_context ──► agent or ai/llm ──► table/apply_record_ops
The model returns operations; the apply node validates and executes them. Because cell values are typed and records are addressed by id, a malformed edit fails at the boundary instead of corrupting the table.
REST access
Tables are fully addressable over the API: /tables for CRUD, /tables/:id/columns (with
/reorder), /tables/:id/records (with /query, /reorder, /batch-delete), and
/tables/:id/views. See the REST API overview.
Next steps
- Document Workspace — the unstructured counterpart.
- Knowledge Bases & Workspace Context — how workspace data reaches an agent.
- ReAct Agent Guide — the agent that consumes the context these nodes build.