Documentation

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:

TypeKey fieldsNotes
content_blockTitle, Body (markdown), ImageIDGeneral-purpose content with optional image
imageMediaURL, AltText, Caption (markdown)Standalone image block
heroHeadline, Subtext (markdown), PrimaryLink (object), SecondaryLink (object), ImageIDPage hero with optional image and CTAs
quoteQuoteText (markdown), Attribution, Context (markdown)Pull quote
contact_cardName, JobTitle, Body (markdown), Email, ImageIDTeam member / contact
faq_itemQuestion, Answer (markdown)One Q&A pair
link_itemTitle, URL, Body (markdown), IsCTA (boolean)One navigational link with description
html_blockHTML (raw HTML)Trusted verbatim HTML — iframes, embeds
footerBody (markdown)Footer content section

Collection blocks (7) — hold ordered children of a leaf type:

TypeTypical children
content_gridcontent_block items
galleryimage items
faqfaq_item items
link_collectionlink_item items
html_gridhtml_block items
teamcontact_card items
content_listlatest content items (dynamic, from a registered module)

Structural root:

TypePurpose
pageMinimal 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 caseApproach
Content with its own lifecycle (post, story, doc page) that should also host structured block sectionsBlockHost() on the module
Standalone composition surface with no content lifecycletype_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:

KeyValue
.IDBlock UUID
.SlugBlock slug (may be empty)
.Status"published"
.AnchorIDStable id attribute value for the block's HTML element
All Fields keysPromoted 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 ImageID renders nothing, no error
  • Batched — one extra query per page regardless of how many blocks carry a reference
  • One level.Image carries 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.

ToolDescription
create_nodeCreate a Draft block. Params: type_name (string), fields (JSON object). Returns id. Fields validated for schematised types (v1.38.0+).
update_nodeMerge fields onto a block by id. Absent keys are preserved; type_name cannot change. Fields validated for schematised types (v1.38.0+).
get_nodeRead a block by id at any status.
list_nodesList blocks. Optional filters: type_name, status.
publish_nodePublish a block by id. Idempotent.
archive_nodeArchive 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.

ToolDescription
add_sectionAppend child_id as a section of parent_id.
reorder_sectionsSet the section order of parent_id. Params: parent_id, ordered_child_ids (array).
remove_sectionRemove child_id from parent_id's sections.
add_itemAppend child_id as an item inside a collection block.
reorder_itemsSet the item order of a collection block.
remove_itemRemove 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)

ToolDescription
list_content_type_schemasReturns all registered schemas with type_name and label. Use to enumerate available block types.
get_content_type_schemaReturns 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

PropertyBehaviour
Only Published blocks renderDraft and Archived blocks are silently skipped
Graceful degradationMissing block, missing template, malformed Fields: skipped + logged, never a page error
No N+1 queriesEach depth level loads in one batched query
Cycle-safeA 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 blocksA 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 typeMarkdown fieldsRaw HTML fieldsReference fields
content_blockBodyImageID
imageCaption
heroSubtextImageID
quoteQuoteText, Context
contact_cardBodyImageID
faq_itemAnswer
link_itemBody
html_blockHTML
footerBody
content_gridSubtitle
gallerySubtitle
faqSubtitle
link_collectionSubtitle
html_gridSubtitle
teamSubtitle
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.