PageWeave / Docs

Overview

Markdown

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 toolslist_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 templatingsite.tables.{slug} in page HTML
  3. Public JSON APIGET /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)

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 or created_at
  • order_dirasc 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:

<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:

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