Block system
Smeldr's block system lets you build pages from typed, reusable pieces — without code changes and without redeployment. A page is a set of edges in a database. An AI agent or a developer arranges those edges over MCP or CLI. The server renders the result via convention templates.
This guide covers everything you need to use the block system: the data model, rendering setup, the 16 shipped block types, MCP tools, and the CLI.
What a block is
A block is a DynamicNode — the standard Smeldr content lifecycle (Draft, Published, Archived) plus two fields:
type DynamicNode struct {
smeldr.Node // ID, Slug, Status, timestamps
TypeName string `db:"type_name"` // e.g. "hero", "faq_item"
Fields json.RawMessage `db:"fields"` // type-specific data as JSON
}
The TypeName selects the field schema and the render template. The Fields payload carries the actual content. One Go type serves every block type — no new struct for each layout.
All blocks live in a single table, smeldr_dynamic_content. Adding a new block type requires no schema migration.
The 16 rendered block types
16 block types render via templates (each maps to a templates/blocks/<type_name>.html file). These are distinct from the page structural root, which acts as a composition container but has no template and never renders directly. The 16 fall into two families:
Leaf blocks (9) — carry content directly:
| Type | Key fields | Notes |
|---|---|---|
content_block | Title, Body (markdown), ImageID | General-purpose content with optional image |
image | MediaURL, AltText, Caption (markdown) | Standalone image block |
hero | Headline, Subtext (markdown), PrimaryLink (object), SecondaryLink (object), ImageID | Page hero with optional image and CTAs |
quote | QuoteText (markdown), Attribution, Context (markdown) | Pull quote |
contact_card | Name, JobTitle, Body (markdown), Email, ImageID | Team member / contact |
faq_item | Question, Answer (markdown) | One Q&A pair |
link_item | Title, URL, Body (markdown), IsCTA (boolean) | One navigational link with description |
html_block | HTML (raw HTML) | Trusted verbatim HTML — iframes, embeds |
footer | Body (markdown) | Footer content section |
Collection blocks (7) — hold ordered children of a leaf type:
| Type | Typical children |
|---|---|
content_grid | content_block items |
gallery | image items |
faq | faq_item items |
link_collection | link_item items |
html_grid | html_block items |
team | contact_card items |
content_list | latest content items (dynamic, from a registered module) |
Structural root:
| Type | Purpose |
|---|---|
page | Minimal structural container — owns sections via composition edges. Not a rendered block; has no template. See note below. |
Composition: pages are edges
A page is not a special row — it is a root node that owns other nodes via edges. smeldr_content_edges records that a parent contains a child at a given position. The same table serves both "this page has these sections" and "this gallery has these images."
smeldr_content_edges (
id TEXT PRIMARY KEY,
parent_id TEXT NOT NULL,
parent_type TEXT NOT NULL,
child_id TEXT NOT NULL,
child_type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
is_shared INTEGER NOT NULL DEFAULT 0, -- 1 when child appears in multiple parents
edge_role TEXT NOT NULL -- "section" or "item"
)
Sections are the top-level children of a page. Items are the children of a collection block. Both edges live in the same table, distinguished only by edge_role.
A block can appear in multiple pages (IsShared = true). A footer block, for example, can be a section on every page of the site — edit it once and every page reflects the change instantly.
Content types as block parents (v1.37.0+)
Any registered content type — Post, DocPage, Story, any module — can declare that its instances host block sections. Add the BlockHost() option to NewModule:
postModule := smeldr.NewModule((*Post)(nil),
smeldr.Repo(postRepo),
smeldr.At("/posts"),
smeldr.BlockHost(),
)
app.Content(postModule)
app.Content() detects BlockHost() and auto-registers the module as a ContentParentProvider. Once registered, add_section and add_item accept a post ID as parent_id:
{ "parent_id": "<post-uuid>", "child_id": "<hero-uuid>" }
The MCP server resolves the parent type by iterating registered providers, finds the module whose BlockParentTypeName() matches the stored parent_type, and calls HasBlockParent to confirm the ID exists. An unknown ID returns an error — not a silent drop.
ContentParentProvider interface — for external providers that are not Smeldr modules:
type ContentParentProvider interface {
BlockParentTypeName() string
HasBlockParent(ctx context.Context, id string) (bool, error)
}
Register external providers manually:
app.RegisterBlockParent(myCustomProvider)
Module[T] with BlockHost() implements this interface automatically. The type name is the lower-case Go type name — Post → "post", DocPage → "docpage".
BlockHost() vs. a page node:
| Use case | Approach |
|---|---|
| Content with its own lifecycle (post, story, doc page) that should also host structured block sections | BlockHost() on the module |
| Standalone composition surface with no content lifecycle | type_name: "page" structural node |
Setting up ServeBlocks
App.ServeBlocks is the rendering entry point. It:
1. Creates the block tables if they do not exist (CreateBlockTables). 2. Parses every <type_name>.html in the given directory into its own template. 3. Returns a *BlockRenderer ready for concurrent use.
// In main.go — after app.Run wiring:
renderer, err := app.ServeBlocks("templates/blocks")
if err != nil {
log.Fatal(err)
}
// In a request handler — render the page with the given ID:
html, err := renderer.Render(ctx, "page", pageID)
if err != nil {
// database error; rendering failures are logged but never returned
http.Error(w, "page unavailable", 500)
return
}
Render returns the ordered HTML of all Published sections. A missing block, a draft, a dangling edge, a malformed Fields payload, or a missing template is skipped and logged — it never causes a 500 or a partial render error.
Writing templates
One HTML file per block type, at templates/blocks/<type_name>.html:
templates/
blocks/
hero.html
content_block.html
gallery.html
faq_item.html
...
Each template receives a map[string]any with:
| Key | Value |
|---|---|
.ID | Block UUID |
.Slug | Block slug (may be empty) |
.Status | "published" |
.AnchorID | Stable id attribute value for the block's HTML element |
All Fields keys | Promoted to top level — .Title, .Body, etc. |
.Layout | (collections only) layout hint string |
.Items | (collections only) []template.HTML — pre-rendered item HTML |
Markdown fields are pre-rendered to template.HTML (XSS-safe). Raw HTML fields are passed through as template.HTML. Plain string fields are auto-escaped by html/template.
The PascalCase rule
Block Fields keys must be PascalCase. Templates access .Title and .Body, not .title and .body. A block stored with snake_case keys renders blank without error — this is the one trap worth watching.
When creating blocks via create_node (MCP) or directly via the API, always use PascalCase keys:
{ "Title": "Welcome", "Subtext": "Built for agents" } ✓ correct
{ "title": "Welcome", "subtext": "Built for agents" } ✗ renders blank
Reference fields: ImageID → .Image
A field named {Name}ID that holds another block's ID is resolved by ServeBlocks into a .{Name} sub-object at render time. For example, ImageID on a hero block resolves to .Image, carrying the referenced image block's full data:
<!-- templates/blocks/hero.html -->
<section class="hero">
<h1>{{ .Headline }}</h1>
<p>{{ .Subtext }}</p>
{{ with .Image }}
<img src="{{ .MediaURL }}" alt="{{ .AltText }}">
{{ end }}
</section>
Resolution is:
- Published-only — a draft image renders nothing (the
{{ with }}block is empty) - Guarded — a missing or dangling
ImageIDrenders nothing, no error - Batched — one extra query per page regardless of how many blocks carry a reference
- One level —
.Imagecarries plain data, not further-resolved references
To show an image inside a block: 1. Create and publish an image block. 2. Set the parent block's ImageID to the image block's ID.
Collection templates
A collection template receives its pre-rendered children in .Items:
<!-- templates/blocks/gallery.html -->
<div class="gallery" data-layout="{{ .Layout }}">
{{ range .Items }}{{ . }}{{ end }}
</div>
Each item in .Items is already rendered HTML from its own template. The collection template only needs to wrap them.
Managing blocks with MCP
The MCP block tools are available when smeldr.dev/mcp is configured with mcp.WithBlocks():
mcpSrv := mcp.New(app, mcp.WithBlocks())
Node lifecycle (Author role)
Blocks are addressed by ID — they have no slug and are not browsable resources.
| Tool | Description |
|---|---|
create_node | Create a Draft block. Params: type_name (string), fields (JSON object). Returns id. Fields validated for schematised types (v1.38.0+). |
update_node | Merge fields onto a block by id. Absent keys are preserved; type_name cannot change. Fields validated for schematised types (v1.38.0+). |
get_node | Read a block by id at any status. |
list_nodes | List blocks. Optional filters: type_name, status. |
publish_node | Publish a block by id. Idempotent. |
archive_node | Archive a block by id. |
Example — create and publish a hero block:
// create_node
{ "type_name": "hero", "fields": { "Headline": "Welcome", "Subtext": "Built for agents" } }
// → { "id": "01HZ..." }
// publish_node
{ "id": "01HZ..." }
Composition (Editor role)
Compose blocks into pages and collections. Pass only IDs — the tool derives parent and child types automatically from the stored blocks.
| Tool | Description |
|---|---|
add_section | Append child_id as a section of parent_id. |
reorder_sections | Set the section order of parent_id. Params: parent_id, ordered_child_ids (array). |
remove_section | Remove child_id from parent_id's sections. |
add_item | Append child_id as an item inside a collection block. |
reorder_items | Set the item order of a collection block. |
remove_item | Remove an item from a collection block. |
Example — build a landing page:
// 1. Create the page root:
{ "type_name": "page", "fields": { "Title": "Home" } } // → page_id
// 2. Create and publish a hero:
{ "type_name": "hero", "fields": { "Headline": "Hello" } } // → hero_id
// publish_node { "id": hero_id }
// 3. Add the hero as a section:
{ "parent_id": "page_id", "child_id": "hero_id" } // add_section
Typed block creation (v1.38.0+)
Schema-defined block types give agents typed, validated parameters for every canonical block type — instead of an unguided fields JSON bag.
Startup
Add two idempotent calls alongside your block table setup:
func migrateDB(db *sql.DB) error {
if err := smeldr.CreateBlockTables(db); err != nil { return err }
if err := smeldr.CreateSchemaTable(db); err != nil { return err }
return smeldr.SeedBlockTypeSchemas(db)
}
CreateSchemaTable creates smeldr_content_type_schemas if it does not exist. SeedBlockTypeSchemas inserts the 16 canonical schemas using INSERT OR IGNORE — safe to call on every boot, and existing rows are never overwritten.
16 typed create tools
Once schemas are seeded, the MCP server generates one typed tool per schema at startup. Each tool is named create_<type_name> and exposes the schema's fields as named JSON Schema parameters with types, required markers, and descriptions. Field values are passed directly — not nested under a fields key as in create_node:
// Typed tool — fields are top-level parameters:
{ "Headline": "Welcome", "Subtext": "Built for agents" } // create_hero
// Generic tool — fields nested under "fields":
{ "type_name": "hero", "fields": { "Headline": "Welcome" } } // create_node
create_node remains available and unchanged. Typed tools are additive — they supplement, never replace, the generic interface.
The 16 generated tools:
create_content_block create_image create_hero
create_quote create_contact_card create_faq_item
create_link_item create_html_block create_footer
create_content_grid create_gallery create_faq
create_link_collection create_html_grid create_team
create_content_list
Schema discovery tools (Author role)
| Tool | Description |
|---|---|
list_content_type_schemas | Returns all registered schemas with type_name and label. Use to enumerate available block types. |
get_content_type_schema | Returns the full field spec for one type_name: label and an array of field descriptors (name, type, required, format, description). |
Example — discover the hero schema before creating:
// get_content_type_schema { "type_name": "hero" }
// → {
// "type_name": "hero",
// "label": "Hero",
// "fields": [
// {"name": "Headline", "type": "string", "required": true},
// {"name": "Subtext", "type": "string", "format": "markdown"},
// {"name": "ImageID", "type": "string", "description": "ID of an Image block"},
// {"name": "PrimaryLink", "type": "object", "description": "Primary CTA link sub-object"},
// {"name": "SecondaryLink", "type": "object", "description": "Secondary link sub-object"}
// ]
// }
Field validation
create_node and update_node validate the fields payload against the schema when a schema exists for the given type_name:
- Unknown fields — rejected with an error.
- Missing required fields — rejected with an error.
- No schema — passes through unchanged (backwards compatibility for custom types).
Managing blocks with the CLI
smeldr-cli block mirrors the MCP surface for the human fallback.
Node commands (Author role)
# Create a draft block
smeldr-cli block node create --type hero --field Headline="Welcome" --field Subtext="Hello"
# Create with a JSON fields object
smeldr-cli block node create --type content_block --fields '{"Title":"Post","Body":"..."}'
# List blocks (aligned table: ID, type_name, status, slug)
smeldr-cli block node list
smeldr-cli block node list --type hero --status published
smeldr-cli block node list --json # raw JSON
# Get, publish, archive
smeldr-cli block node get <id>
smeldr-cli block node publish <id>
smeldr-cli block node archive <id>
# Update (merges fields — absent keys preserved)
smeldr-cli block node update <id> --field Headline="New headline"
Important: --field flags preserve key casing. Use PascalCase:
--field Headline="..." ✓
--field headline="..." ✗ renders blank
Composition commands (Editor role)
# Sections
smeldr-cli block section add <page_id> <block_id>
smeldr-cli block section reorder <page_id> <id1,id2,id3>
smeldr-cli block section remove <page_id> <block_id>
# Items (inside a collection block)
smeldr-cli block item add <collection_id> <item_id>
smeldr-cli block item reorder <collection_id> <id1,id2,id3>
smeldr-cli block item remove <collection_id> <item_id>
Behaviour guarantees
| Property | Behaviour |
|---|---|
| Only Published blocks render | Draft and Archived blocks are silently skipped |
| Graceful degradation | Missing block, missing template, malformed Fields: skipped + logged, never a page error |
| No N+1 queries | Each depth level loads in one batched query |
| Cycle-safe | A visited-set and depth limit (16) prevent infinite loops on shared blocks |
| Reference resolution | {Name}ID fields resolved in one extra batch query per page |
| Shared blocks | A block can appear in multiple pages; editing it updates all pages |
Reference: block field formats
Fields in Fields are either plain strings, Markdown (rendered to HTML before reaching the template), or raw HTML.
| Block type | Markdown fields | Raw HTML fields | Reference fields |
|---|---|---|---|
content_block | Body | — | ImageID |
image | Caption | — | — |
hero | Subtext | — | ImageID |
quote | QuoteText, Context | — | — |
contact_card | Body | — | ImageID |
faq_item | Answer | — | — |
link_item | Body | — | — |
html_block | — | HTML | — |
footer | Body | — | — |
content_grid | Subtitle | — | — |
gallery | Subtitle | — | — |
faq | Subtitle | — | — |
link_collection | Subtitle | — | — |
html_grid | Subtitle | — | — |
team | Subtitle | — | — |
content_list | — | — | — |
As of v1.38.0, list_content_type_schemas and get_content_type_schema are the authoritative sources for field names, types, and required markers for all 16 canonical block types.