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.
- Log in to
http://localhost:8099/admin. - Open Security → MCP Tokens (path
/admin/security/mcp-tokens; the legacy/admin/mcp-tokensURL still redirects). - Click Create token, pick a label and an optional scope (read / content / admin), copy the token — it is shown once, on creation.
- Point your MCP client at
http://localhost:8099/mcpwith that token. From a Claude Desktop or Cursor config, this is a one-line entry; from the AnthropicmcpCLI 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).
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" }
]
})
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 type — type is reserved on the block envelope, and the asymmetric naming is deliberate so the wrong key fails loudly instead of silently dropping data.
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 usefields_data. Insideblocks_data, every entry is{ type, fields }; at the top level of a node, the same data goes underfields_data. Misnaming silently drops data. - Status is one of
draftorpublished. Drafts don't render publicly but do show in/adminpreviews. layout_slugpicks the theme layout. The bundleddefaulttheme shipsdefault,post,docs, anderror. Runcore.layout.listagainst your active theme. Node-type-specific defaults are not auto-applied by the kernel — setlayout_slugexplicitly 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.
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
recipestable because it didn't need to.fields_datais one JSONB column oncontent_nodes. - Recompile anything — the entire flow is metadata + JSONB writes.
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.