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:
- 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 - Liquid templating —
site.tables.{slug}in page HTML - 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 nameslug— URL-safe identifier (auto-generated from name if omitted)fields_schema—{ fields: [{ name, type }] }- Types:
string,text,number,boolean,datetime
- Types:
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"(ornull) 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)
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".
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}:
{% for row in site.tables.products %}
<li>{{ row.name }} — ${{ row.price }}</li>
{% endfor %}
Filtering
{% for row in site.tables.products | where: "category", "electronics" %}
{{ row.name }}
{% endfor %}
Multiple filters chain together:
{% assign filtered = site.tables.products | where: "category", "electronics" | where: "in_stock", "true" %}
Ordering
{% for row in site.tables.products | sort_by: "price", "asc" %}
{{ row.name }}: ${{ row.price }}
{% endfor %}
Pagination
{% 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 orcreated_atorder_dir—ascordescwhere— Filter expression ("field:value")
Row Properties
Every row exposes:
row.id— UUIDrow.slug— the value of theslugdata field (alias forrow.data.slug)row.created_at— Timestamprow.updated_at— Timestamprow.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:
<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 vialist_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:
curl "https://my-site.pageweave.site/t/abc123-def456"
Pagination:
curl "https://my-site.pageweave.site/t/abc123-def456?page=2&per=10"
Sorting:
# 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:
# 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:
curl "https://my-site.pageweave.site/t/abc123-def456?search=blue widget"
Combined:
curl "https://my-site.pageweave.site/t/abc123-def456?page=1&per=20&sort=-price&filter[category]=electronics&filter[price_gte]=10"
Response Shape
{
"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
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 |