# Tables (`table`)

Rows and columns of data: orders, availability, price comparisons. Display-only unless select is set.

**When to use it:** You show rows of the same kind side by side: orders, availability, a price comparison. It only displays data unless you add select, which lets the person pick one or several rows.

**When not to use it:** Do not use it for a chart-like comparison (use `chart`) or for a few options to choose from with photos and prices (use the matching picker).

**Gather first:**

- The rows and which columns matter to the person.
- Whether they only read it, or pick rows (`select`).

**Writing good content:**

- Use typed columns (`money` with a currency, `percent`, `date`, `status`) so values format and sort correctly.
- Keep to the 8 columns that matter; make key columns `sortable` and add `search` above about 15 rows.
- Without `select` the table is display-only and returns no answer; add `select` with a `prompt` when you need the person to pick rows.

**Display-only when `select` is omitted:** in that case the page shows information only, has no Submit button and accepts no answer. Otherwise the person answers as below.

## Fields

Every page also has `version` (always `1`), `title`, and optionally `description`, `branding`, `cover` (a header photo) and `expiresAt`; see [Page fields](/docs/api#page-fields). These are the fields of `page` (the page type and its data):

| Field | Description |
| --- | --- |
| `columns` | *object[], required.* The columns, in display order (1-8). Limits: 1-8 items. |
| `columns[].id` | *string, required.* Column id, used as the key in every row's values, e.g. "total". Unique. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `columns[].label` | *string, required.* Header text, e.g. "Total". Required. Limits: 1-40 characters. |
| `columns[].type` | *"text" \| "number" \| "money" \| "percent" \| "date" \| "datetime" \| "status" \| "link" \| "image", required.* Cell type, which fixes the value shape: text = string; number, money (major units, e.g. 24.5) and percent (12.5 means 12.5%) = number; date = "2026-11-03"; datetime = ISO 8601 with offset; status = { label, tone: "neutral" \| "good" \| "warn" \| "bad" }; link = { url (https), label? }; image = { url (https thumbnail), alt? }. |
| `columns[].align` | *"start" \| "center" \| "end".* Optional. Defaults to "end" for number, money and percent, and "start" for everything else. |
| `columns[].format` | *object.* Optional number formatting. Money columns need { currency }. |
| `columns[].format.currency` | *string.* ISO 4217 code for a money column, e.g. "USD". Required when type is "money". |
| `columns[].format.decimals` | *integer.* Optional fixed number of decimals for number, money and percent. Limits: 0-4. |
| `columns[].priority` | *"high" \| "normal".* Optional. On phones each row becomes a card: "high" columns are shown large at the top, the rest as label/value pairs below. Mark the 1-3 columns a person scans for. Defaults to the first non-image column only. |
| `columns[].sortable` | *boolean.* Optional. true lets the person sort by this column. |
| `rows` | *object[], required.* The rows, in default order (0-200; at least 1 with select). An empty list shows an empty state. Limits: at most 200 items. |
| `rows[].id` | *string, required.* Row id. It is returned in the result when the person selects this row. Unique. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `rows[].values` | *object, required.* Cell values keyed by column id, e.g. { "item": "Desk lamp", "total": 49 }. Omit a key for an empty cell. |
| `caption` | *string.* Optional table name read by screen readers and shown above it, e.g. "Orders since March". Limits: 1-120 characters. |
| `summaryRow` | *object.* Optional totals row at the bottom, e.g. { "values": { "total": 412.5 } }. It is never sorted or filtered. |
| `summaryRow.label` | *string.* Optional label in the first column. Defaults to "Total". Limits: 1-40 characters. |
| `summaryRow.values` | *object, required.* Cell values keyed by column id, e.g. { "item": "Desk lamp", "total": 49 }. Omit a key for an empty cell. |
| `sort` | *object.* Optional initial sort. Without it rows keep the order you sent. |
| `sort.columnId` | *string, required.* A sortable column id. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `sort.direction` | *"asc" \| "desc", required.* |
| `search` | *boolean.* Optional. true shows a search box that filters rows as the person types. Useful above ~15 rows. |
| `groupBy` | *string.* Optional column id (text or status) whose value groups the rows under headings. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `select` | *object.* Optional. Makes rows selectable and adds a Submit button; the result is { rowIds }. Without it the table only shows data. |
| `select.mode` | *"single" \| "multiple", required.* "single" for one row, "multiple" for several. |
| `select.max` | *integer.* Optional, multiple only: the most rows the person may pick. Limits: 1-200. |
| `select.prompt` | *string.* Optional instruction above the table, e.g. "Pick the orders to return". Limits: 1-120 characters. |

**Why these are required:** A table needs typed columns and rows with ids; `values` is keyed by column id and each value must match its column's type (an empty `{}` is an empty row). Display-only unless `select` is set.

**When optional fields are left out:** No caption; no totals row; rows keep the order sent; no search box; no groups; display-only (no Submit, every result rejected). Numbers, money and percent align right, the rest left. Money columns need `format.currency`. Without any `priority: "high"`, phone cards lead with the first non-image column and list the rest as label/value pairs. Columns are not sortable unless `sortable: true`. Empty cells show a dash. With `select`, the result is `{ rowIds }` in spec order; `single` allows one row, `multiple` up to `max` (default all).

## Minimal example

Only the required fields:

```json
{
  "version": 1,
  "title": "Guests confirmed so far",
  "page": {
    "type": "table",
    "columns": [
      {
        "id": "name",
        "label": "Name",
        "type": "text"
      }
    ],
    "rows": [
      {
        "id": "g1",
        "values": {
          "name": "Priya Shah"
        }
      },
      {
        "id": "g2",
        "values": {
          "name": "Tomás Ortega"
        }
      }
    ]
  }
}
```

## Full example

```json
{
  "version": 1,
  "title": "Which orders should I return?",
  "description": "Returns are free until Nov 30. Pick up to three.",
  "page": {
    "type": "table",
    "caption": "Returnable orders",
    "columns": [
      {
        "id": "item",
        "label": "Item",
        "type": "text",
        "priority": "high",
        "sortable": true
      },
      {
        "id": "ordered",
        "label": "Ordered",
        "type": "date",
        "sortable": true
      },
      {
        "id": "status",
        "label": "Status",
        "type": "status",
        "priority": "high"
      },
      {
        "id": "qty",
        "label": "Qty",
        "type": "number"
      },
      {
        "id": "total",
        "label": "Total",
        "type": "money",
        "format": {
          "currency": "USD"
        },
        "priority": "high",
        "sortable": true
      },
      {
        "id": "tracking",
        "label": "Tracking",
        "type": "link"
      }
    ],
    "rows": [
      {
        "id": "o-1042",
        "values": {
          "item": "Linen duvet cover, queen",
          "ordered": "2026-09-28",
          "status": {
            "label": "Out for delivery",
            "tone": "warn"
          },
          "qty": 1,
          "total": 189,
          "tracking": {
            "url": "https://track.example.test/1042",
            "label": "LX1042"
          }
        }
      },
      {
        "id": "o-1037",
        "values": {
          "item": "Stoneware mugs (set of 4)",
          "ordered": "2026-09-14",
          "status": {
            "label": "Delivered",
            "tone": "good"
          },
          "qty": 2,
          "total": 96,
          "tracking": {
            "url": "https://track.example.test/1037",
            "label": "LX1037"
          }
        }
      },
      {
        "id": "o-1029",
        "values": {
          "item": "Oak bedside table",
          "ordered": "2026-08-30",
          "status": {
            "label": "Delivered",
            "tone": "good"
          },
          "qty": 1,
          "total": 245.5,
          "tracking": {
            "url": "https://track.example.test/1029",
            "label": "LX1029"
          }
        }
      }
    ],
    "select": {
      "mode": "multiple",
      "max": 3,
      "prompt": "Select the orders to return"
    }
  }
}
```

## Result

When the person submits, the result has these fields:

| Field | Description |
| --- | --- |
| `rowIds` | *string[], required.* ids of the selected rows, in the order the spec lists them. Limits: 1-200 items. |

For the full example above, a result could be:

```json
{
  "rowIds": [
    "o-1042",
    "o-1029"
  ]
}
```

## Send it

### eve, AI SDK and MCP

With [`@pagesystems/eve`](/docs#quickstart-eve), [`@pagesystems/ai-sdk`](/docs#quickstart-ai-sdk) or the [MCP server](/docs#quickstart-mcp), the model calls `get_page_type_schema` with `"table"` (the same JSON as [`GET /v1/page-types/table`](/docs/api#endpoints)), then `create_page` with this input (no `version` or `branding`; branding comes from your dashboard):

```json
{
  "title": "Which orders should I return?",
  "description": "Returns are free until Nov 30. Pick up to three.",
  "page": {
    "type": "table",
    "caption": "Returnable orders",
    "columns": [
      {
        "id": "item",
        "label": "Item",
        "type": "text",
        "priority": "high",
        "sortable": true
      },
      {
        "id": "ordered",
        "label": "Ordered",
        "type": "date",
        "sortable": true
      },
      {
        "id": "status",
        "label": "Status",
        "type": "status",
        "priority": "high"
      },
      {
        "id": "qty",
        "label": "Qty",
        "type": "number"
      },
      {
        "id": "total",
        "label": "Total",
        "type": "money",
        "format": {
          "currency": "USD"
        },
        "priority": "high",
        "sortable": true
      },
      {
        "id": "tracking",
        "label": "Tracking",
        "type": "link"
      }
    ],
    "rows": [
      {
        "id": "o-1042",
        "values": {
          "item": "Linen duvet cover, queen",
          "ordered": "2026-09-28",
          "status": {
            "label": "Out for delivery",
            "tone": "warn"
          },
          "qty": 1,
          "total": 189,
          "tracking": {
            "url": "https://track.example.test/1042",
            "label": "LX1042"
          }
        }
      },
      {
        "id": "o-1037",
        "values": {
          "item": "Stoneware mugs (set of 4)",
          "ordered": "2026-09-14",
          "status": {
            "label": "Delivered",
            "tone": "good"
          },
          "qty": 2,
          "total": 96,
          "tracking": {
            "url": "https://track.example.test/1037",
            "label": "LX1037"
          }
        }
      },
      {
        "id": "o-1029",
        "values": {
          "item": "Oak bedside table",
          "ordered": "2026-08-30",
          "status": {
            "label": "Delivered",
            "tone": "good"
          },
          "qty": 1,
          "total": 245.5,
          "tracking": {
            "url": "https://track.example.test/1029",
            "label": "LX1029"
          }
        }
      }
    ],
    "select": {
      "mode": "multiple",
      "max": 3,
      "prompt": "Select the orders to return"
    }
  }
}
```

### REST

```bash
curl https://pagesystems.ai/v1/pages \
  -H "Authorization: Bearer $PAGE_SYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spec":{"version":1,"title":"Which orders should I return?","description":"Returns are free until Nov 30. Pick up to three.","page":{"type":"table","caption":"Returnable orders","columns":[{"id":"item","label":"Item","type":"text","priority":"high","sortable":true},{"id":"ordered","label":"Ordered","type":"date","sortable":true},{"id":"status","label":"Status","type":"status","priority":"high"},{"id":"qty","label":"Qty","type":"number"},{"id":"total","label":"Total","type":"money","format":{"currency":"USD"},"priority":"high","sortable":true},{"id":"tracking","label":"Tracking","type":"link"}],"rows":[{"id":"o-1042","values":{"item":"Linen duvet cover, queen","ordered":"2026-09-28","status":{"label":"Out for delivery","tone":"warn"},"qty":1,"total":189,"tracking":{"url":"https://track.example.test/1042","label":"LX1042"}}},{"id":"o-1037","values":{"item":"Stoneware mugs (set of 4)","ordered":"2026-09-14","status":{"label":"Delivered","tone":"good"},"qty":2,"total":96,"tracking":{"url":"https://track.example.test/1037","label":"LX1037"}}},{"id":"o-1029","values":{"item":"Oak bedside table","ordered":"2026-08-30","status":{"label":"Delivered","tone":"good"},"qty":1,"total":245.5,"tracking":{"url":"https://track.example.test/1029","label":"LX1029"}}}],"select":{"mode":"multiple","max":3,"prompt":"Select the orders to return"}}}}'
```

## Validation errors

A spec that does not validate is refused with `400 invalid_spec`. The message names each problem and its path, so a model can fix the spec and try again. For example:

**Leaving out the required field `columns`:**

```text
✖ Invalid input: expected array, received undefined
  → at page.columns
```

**Misspelling a field name (unknown keys are rejected):**

```text
✖ Unrecognized key: "colour"
  → at page
```
