Quickstart

Your first Squilla site in five minutes.

Goal

By the end of this page you'll have created a custom node type, seeded three nodes, and rendered them on a public page — entirely through the MCP interface, with zero filesystem access and zero kernel restarts. The whole flow runs against a stock docker compose up install.

Step 1 — connect via MCP

The kernel exposes a Model Context Protocol server at POST /mcp (the path is the same one any standards-conformant MCP transport speaks JSON-RPC against). Auth is bearer-token: every request must carry Authorization: Bearer <token> and tokens are managed from the admin SPA.

  1. Log in to http://localhost:8099/admin.
  2. Open Security → MCP Tokens (path /admin/security/mcp-tokens; the legacy /admin/mcp-tokens URL still redirects).
  3. Click Create token, pick a label and an optional scope (read / content / admin), copy the token — it is shown once, on creation.
  4. Point your MCP client at http://localhost:8099/mcp with that token. From a Claude Desktop or Cursor config, this is a one-line entry; from the Anthropic mcp CLI it's --header "Authorization: Bearer <token>".

Every tool the kernel exposes is namespaced core.<domain>.<verb> — the verb tells you the side-effect class (get/list/query are reads, create/update/delete are writes, render.* is preview-only and never writes). The full catalogue is at MCP Tool Catalog.

Step 2 — register a content type

The core.nodetype.create tool registers a new node type (a “post type”, in WordPress vocabulary) with its own field schema. Custom types do not get their own SQL tables — the kernel stores all nodes in a single content_nodes table and uses the fields_data JSONB column for typed fields. Adding a node type is a metadata-only operation and reads stay sub-millisecond for typical lookups (blocks_data has a GIN index from migration 0001 for full-text-style queries; fields_data is unindexed by default and is fast enough on its own for the access patterns the admin uses).

MCP call
core.nodetype.create({
  slug: "recipe",
  label: "Recipe",
  label_plural: "Recipes",
  icon: "chef-hat",
  url_prefixes: { en: "recipes" },
  fields: [
    { name: "hero_image",   title: "Hero image",   type: "image"  },
    { name: "prep_minutes", title: "Prep minutes", type: "number" },
    { name: "servings",     title: "Servings",     type: "number" }
  ]
})
PARAMETER NAME
The argument is named fields, not field_schema. Each field is { name, title, type, required?, options?, fields?, initialValue?, description? }. select, radio, and checkbox options must be plain strings, not {label, value} objects — the admin SPA renders them as React children and crashes on objects. Run core.field_types.list to discover every registered type (text, number, image, select, repeater, term, and the extension-contributed types).

Step 3 — seed nodes

Bulk-create three recipes. Note the parameter is node_type, not typetype is reserved on the block envelope, and the asymmetric naming is deliberate so the wrong key fails loudly instead of silently dropping data.

MCP call
core.node.create({
  node_type:     "recipe",
  language_code: "en",
  title:         "Caramelised scallops",
  slug:          "caramelised-scallops",
  status:        "published",
  layout_slug:   "default",
  fields_data: {
    prep_minutes: 18,
    servings: 2
  },
  blocks_data: [
    { type: "text", fields: { body: "<p>Sear hot, finish low.</p>" } }
  ]
})

A few invariants worth committing to memory before you scale the script up:

  • Block envelopes use fields, node envelopes use fields_data. Inside blocks_data, every entry is { type, fields }; at the top level of a node, the same data goes under fields_data. Misnaming silently drops data.
  • Status is one of draft or published. Drafts don't render publicly but do show in /admin previews.
  • layout_slug picks the theme layout. The bundled default theme ships default, post, docs, and error. Run core.layout.list against your active theme. Node-type-specific defaults are not auto-applied by the kernel — set layout_slug explicitly when authoring sections that need a non-default layout.
  • Slugs are theme-portable; numeric IDs are not. When you reference media or layouts in seed scripts, prefer slug lookups — IDs rotate when themes are reactivated.

Step 4 — render preview

Without ever publishing or restarting anything, you can preview a node with core.render.node_preview. The kernel resolves the slug, runs the full rendering pipeline (layout + blocks + theme CSS), and returns the HTML exactly as the public site would serve it. The call is side-effect-free — no events, no view counts, no audit-log entries — so it's safe to call repeatedly while iterating.

MCP call
core.render.node_preview({ id: 123 })
// returns { html: "<!doctype html>...", warnings: [...] }
//
// Inspect warnings[] before declaring done. Common entries:
//   - missing_block_view: a blocks_data entry references a slug with no view.html
//   - missing_partial: a layout {{ template "x" }} call has no matching partial
//   - field_unknown: fields_data has keys not in the node type's schema
//
// For a single-block smoke test (no layout, no chrome) use
// core.render.block({ block_type: "text", fields: {...} }).

What you didn't have to do

  • Touch the filesystem.
  • Restart the kernel — node-type registration, content writes, and layout swaps are all hot.
  • Run a database migration — the kernel did not create a recipes table because it didn't need to. fields_data is one JSONB column on content_nodes.
  • Recompile anything — the entire flow is metadata + JSONB writes.
REVERSIBILITY
The whole flow is reversible. core.nodetype.delete({ slug: "recipe" }) drops the type. Existing recipe nodes are not destroyed — they go dormant (hidden from listings, preserved on disk). Re-running core.nodetype.create with the same slug resurrects them with their fields_data intact. Node revisions are full snapshots since migration 0041, and core.node.revision_restore captures the pre-restore state as a fresh revision so the restore itself is reversible.