Custom Tables

Structured content — posts, products, events, anything tabular. Create tables through the API or MCP, then query them from templates and handlers.

Creating tables

Table names must start with __. An id (TEXT PRIMARY KEY), created_at, and updated_at are added automatically:

POST /api/tables
{
  "name": "__posts",
  "columns": [
    { "name": "title", "type": "TEXT", "nullable": false },
    { "name": "slug", "type": "TEXT", "unique": true },
    { "name": "body", "type": "TEXT" },
    { "name": "published", "type": "BOOLEAN" },
    { "name": "views", "type": "INTEGER" },
    { "name": "rating", "type": "REAL" }
  ]
}

Supported column types: TEXT, INTEGER, REAL, NUMERIC, BLOB, BOOLEAN, DATETIME, DATE, JSON. Each column accepts nullable (default true) and unique.

SQL Guard applies: the db data source and ctx.db.query only run data statements. Schema lives here — in the custom tables API — not in templates.

Querying from templates

Use the db data source with the __-prefixed name:

{
  "posts": {
    "type": "db",
    "query": "SELECT * FROM __posts ORDER BY created_at DESC LIMIT 10"
  },
  "post": {
    "type": "db",
    "queryOne": "SELECT * FROM __posts WHERE slug = ?",
    "params": ["params.slug"]
  }
}

Querying from handlers

const posts = await ctx.db.query("SELECT * FROM __posts WHERE published = 1");
return ctx.json(posts.rows);

Per-table CRUD

Creating a table automatically exposes 5 CRUD tools over MCP and the API — list, get, create, update (upsert), delete — scoped to that table. Your agent can manage rows without writing any SQL:

▸ "Add a blog with posts and tags"
✓ table __posts created (+ 5 CRUD tools)
✓ pages /blog and /blog/:slug wired to it

Dropping tables

drop_custom_table (or DELETE /api/tables/:name) removes the table and all its data — the CLI's snapshot tool gives you a restore point if you need one.