---
title: "Documentation — PageWeave"
---

> For the full documentation index, see [PageWeave Documentation](https://pageweave.dev/docs) or fetch [llms.txt](https://pageweave.dev/llms.txt).

# Data Tables

Tables store structured data per website. Each table has a schema with typed fields (`string`, `text`, `number`, `boolean`, `datetime`). Rows are created, updated, and deleted via MCP tools, or bulk-imported from CSV.

Tables are accessible in three ways:

1. **MCP tools** — `list_tables`, `create_table`, `update_table`, `list_table_rows`, `create_table_row`, `update_table_row`, `delete_table_row`, `import_table_rows`, `export_table_csv`
2. **Liquid templating** — `site.tables.{slug}` in page HTML
3. **Public JSON API** — `GET /t/{table_id}` on the website's own domain (no auth required)

## Managing Tables

### Create a Table

```
create_table(website, name: "Products", fields_schema: { fields: [
  { name: "slug", type: "string" },
  { name: "name", type: "string" },
  { name: "price", type: "number" },
  { name: "description", type: "text" }
]})
```

- `name` — Human-readable name
- `slug` — URL-safe identifier (auto-generated from name if omitted)
- `fields_schema` — `{ fields: [{ name, type }] }`
  - Types: `string`, `text`, `number`, `boolean`, `datetime`

If the table will back a template page, include a `slug` string field (or any field name matching a path placeholder) in the schema — template placeholders map verbatim to row data fields.

### Update a Table

```
update_table(table_id, name: "New Name")
```

`name`, `slug`, and `fields_schema` can be updated. Field schema changes must keep every field referenced by a template page's path placeholders.

### List Tables

```
list_tables(website)          # all tables
list_tables(website, table_id: "...")  # single table
```

## Managing Rows

### Create a Row

```
create_table_row(table_id, data: { slug: "widget-a", name: "Widget", price: 29.99 }, published_at: "now")
```

- `data` — Key-value pairs matching the table's field names. For template pages, `slug` (and any other placeholder field) is a **regular data field** whose value you supply — there is no auto-generation.
- `published_at` — `"now"` publishes immediately, an ISO 8601 timestamp schedules publication, `"draft"` (or `null`) leaves the row unpublished. Draft rows are only visible in preview mode.

### List Rows

```
list_table_rows(table_id)                                 # all rows, paginated
list_table_rows(table_id, row_id: "...")                  # single row
list_table_rows(table_id, offset: 20, limit: 10)
list_table_rows(table_id, search: "widget")               # substring across string/text fields
list_table_rows(table_id, filter: { price_gte: 10, active: true })  # field filters (AND)
list_table_rows(table_id, sort: "-price")                 # field asc, -field desc
list_table_rows(table_id, published: true)                # true = published only; false = drafts/scheduled; omit = all
```

`filter` keys are a field name with an optional operator suffix — `_eq`, `_neq`, `_gt`, `_gte`, `_lt`, `_lte`, `_cont`, `_start`, `_end` — the same vocabulary as the public JSON API. `published` narrows to live rows (`true` = published only, `false` = drafts/scheduled only, omit = every row). MCP tools return an error naming the valid options for unknown filter/sort fields, invalid numeric values, and `search` on a table with no string/text fields; the public JSON API ignores unknown fields and invalid numeric values. `row_id` cannot be combined with search/filter/sort/published. Default order: `created_at` desc.

### Update a Row

```
update_table_row(row_id, data: { price: 19.99 })
```

Only provided fields change. Pass `published_at` to change publish state (e.g. `"now"`, `"draft"`, or an ISO 8601 timestamp).

### Delete a Row

```
delete_table_row(row_id)
```

Requires confirmation.

## Bulk Import / Export

`import_table_rows` imports CSV — either a `source_url` the server fetches (HTTPS only, SSRF-guarded, ≤50 MB) or inline `content`. ≤25,000 rows run synchronously and return `created`/`updated`/`skipped`/`failed` keyed by data-row number; larger runs return a `run_id` — poll with `get_import_status`. Unknown columns are ignored.

Publish state is per-row via a reserved `published_at` CSV column: empty = draft (new rows) / keep current (upsert), `-` or `draft` = draft, `now` = publish now, ISO 8601 = schedule.

```
import_table_rows(table_id, content: "slug,name,price\nwidget-a,Widget,29.99", mode: "create", dry_run: true)
```

`mode: "upsert"` matches rows by an `id` column — the export→edit→reimport round-trip. `export_table_csv` emits `id` first plus the table's schema fields, and its output feeds straight back into `import_table_rows` with `mode: "upsert"`.

`export_table_csv` accepts the same `search`/`filter`/`sort`/`published` inputs as `list_table_rows` (drafts included by default; default order `created_at` asc), so the round-trip can cover a filtered subset.

Skip-unchanged: rows whose data matches the current version get no new version. 50 versions are kept per row.

## Liquid Templating

Tables are accessible via `site.tables.{slug}`:

```liquid
{% for row in site.tables.products %}
  <li>{{ row.name }} — ${{ row.price }}</li>
{% endfor %}
```

### Filtering

```liquid
{% for row in site.tables.products | where: "category", "electronics" %}
  {{ row.name }}
{% endfor %}
```

Multiple filters chain together:

```liquid
{% assign filtered = site.tables.products | where: "category", "electronics" | where: "in_stock", "true" %}
```

### Ordering

```liquid
{% for row in site.tables.products | sort_by: "price", "asc" %}
  {{ row.name }}: ${{ row.price }}
{% endfor %}
```

### Pagination

```liquid
{% paginate site.tables.products by 10 order_by: "created_at" order_dir: "desc" %}
  {% for row in site.tables.products %}
    <article>{{ row.name }}</article>
  {% endfor %}
  {% if paginate.next %}<a href="{{ paginate.next.url }}">Next</a>{% endif %}
{% endpaginate %}
```

Pagination parameters:
- `by` — Rows per page (default 20, max 100)
- `order_by` — Field name or `created_at`
- `order_dir` — `asc` or `desc`
- `where` — Filter expression (`"field:value"`)

### Row Properties

Every row exposes:
- `row.id` — UUID
- `row.slug` — the value of the `slug` data field (alias for `row.data.slug`)
- `row.created_at` — Timestamp
- `row.updated_at` — Timestamp
- `row.url` — URL to the row's template page (if configured), in its collapsed canonical form
- Any field from the table schema: `row.name`, `row.price`, etc.

## Template Pages

Associate a page with a table to create dynamic row URLs. The path can contain any number of placeholders — **every placeholder maps verbatim to a string/text field in the table schema** (uniform field semantics). `/products/:slug` maps to a `slug` field you supply in row data; `/blog/:lang/:slug` maps to `lang` and `slug` fields.

```
create_page(website, path: "/products/:slug", html: "...", table_slug: "products")
create_page(website, path: "/blog/:lang/:slug", html: "...", table_slug: "blog")
```

Each row renders at its expanded URL. Template HTML has access to the `row` variable:

```liquid
<article>
  <h1>{{ row.name }}</h1>
  <p>${{ row.price }}</p>
  <div>{{ row.description }}</div>
</article>
```

### Placeholder Defaults (default-locale-unprefixed URLs)

Template pages accept `placeholder_defaults` (via `create_page` / `update_page_settings`): a placeholder value whose URL segment is omitted when the row value equals it. Use it for default-locale-unprefixed sites — e.g. `{ lang: "en" }` on `/docs/:lang/:slug` serves `lang=en` rows at `/docs/seo` and `lang=de` rows at `/docs/de/seo`. A superfluous `/docs/en/seo` request serves the same row with a canonical pointing at the collapsed `/docs/seo`.

### Template Pages and Markdown

Template pages can carry a markdown body alongside the HTML body (or instead of it). Both are Liquid-rendered with the `row` variable — so the markdown body can output `{{ row.content }}` for the current row, served to agents at the `.md` URL and via `Accept: text/markdown`.

### Sitemaps

Template rows are listed in the website's sitemap once each, at their collapsed canonical form (default-locale rows collapsed, other locales prefixed). Draft rows are excluded.

## Public JSON API

Every table serves a public JSON endpoint at `GET /t/{table_id}` on the website's own domain. **No authentication required.** Always-on for all tables.

### Endpoint

```
GET https://{subdomain}.pageweave.site/t/{table_id}
GET https://{hostname}/t/{table_id}
```

- `{table_id}` — Table UUID (not slug). Get it via `list_tables`.

### Query Parameters

| Parameter | Description | Default |
|-----------|-------------|--------|
| `page` | Page number | 1 |
| `per` | Rows per page (max 100) | 20 |
| `sort` | Sort field. Prefix with `-` for descending | `created_at` desc |
| `search` | Full-text search across string/text fields | — |
| `filter[field]` | Exact match on field | — |
| `filter[field_neq]` | Not equal | — |
| `filter[field_gt]` | Greater than (numeric fields) | — |
| `filter[field_gte]` | Greater than or equal | — |
| `filter[field_lt]` | Less than (numeric fields) | — |
| `filter[field_lte]` | Less than or equal | — |
| `filter[field_cont]` | Contains (case-insensitive) | — |
| `filter[field_start]` | Starts with (case-insensitive) | — |
| `filter[field_end]` | Ends with (case-insensitive) | — |

### Examples

**Basic listing:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456"
```

**Pagination:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?page=2&per=10"
```

**Sorting:**

```bash
# Ascending by name
curl "https://my-site.pageweave.site/t/abc123-def456?sort=name"

# Descending by price
curl "https://my-site.pageweave.site/t/abc123-def456?sort=-price"
```

**Filtering:**

```bash
# Exact match
curl "https://my-site.pageweave.site/t/abc123-def456?filter[category]=electronics"

# Numeric range
curl "https://my-site.pageweave.site/t/abc123-def456?filter[price_gte]=10&filter[price_lte]=50"

# Contains (case-insensitive)
curl "https://my-site.pageweave.site/t/abc123-def456?filter[name_cont]=widget"

# Starts with
curl "https://my-site.pageweave.site/t/abc123-def456?filter[name_start]=A"
```

**Search:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?search=blue widget"
```

**Combined:**

```bash
curl "https://my-site.pageweave.site/t/abc123-def456?page=1&per=20&sort=-price&filter[category]=electronics&filter[price_gte]=10"
```

### Response Shape

```json
{
  "rows": [
    {
      "id": "uuid-1",
      "slug": "widget-a",
      "data": { "slug": "widget-a", "name": "Widget A", "price": 29.99, "category": "electronics" },
      "created_at": "2026-06-01T12:00:00Z"
    }
  ],
  "meta": {
    "current_page": 1,
    "next_page": 2,
    "prev_page": null,
    "total_pages": 5,
    "total_count": 47
  }
}
```

(`slug` in the response is an alias of `data["slug"]`.)

### JavaScript Example

```javascript
const res = await fetch("https://my-site.pageweave.site/t/abc123-def456?filter[category]=electronics&sort=-price");
const { rows, meta } = await res.json();

rows.forEach(row => {
  console.log(`${row.data.name}: $${row.data.price}`);
});
```

### CORS

The API sets `Access-Control-Allow-Origin` only when the request `Origin` matches the website's own origins:
- The pageweave.site subdomain URL
- Any configured hostname

External origins receive no CORS headers.

### Rate Limiting

60 GET requests per minute per IP for paths starting with `/t/`. Returns `429 Too Many Requests` when exceeded.

### Error Responses

| Status | Meaning |
|--------|---------|
| 404 | Table not found (invalid UUID or table doesn't exist) |
| 410 | Website is suspended |

