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.

NodeWhat it does
table/queryReads columns and records, paginated
table/apply_record_opsApplies a batch of insert / update / delete operations
table/build_llm_contextPackages 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:

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:

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