The problem
A Smeldr content type maps a Go struct to a row. That model works well for posts, essays, and doc pages — stable shapes with stable fields. But every new page layout requires a new struct, a migration, and a redeploy. A marketing team that needs a campaign page by Friday is waiting on Go code. An AI agent that knows what the page should contain cannot define the type itself or restructure it at runtime.
v1.31.0 changes that with DynamicNode and the block system (T32): core v1.31.0, smeldr.dev/mcp v1.14.0, smeldr.dev/cli v0.10.0.
A page type is a string, not a struct
A DynamicNode carries two fields alongside the standard content lifecycle: a TypeName discriminator and a Fields JSON payload.
repo := smeldr.NewDynamicContentRepo(db)
hero := &smeldr.DynamicNode{
Node: smeldr.Node{ID: smeldr.NewID(), Status: smeldr.Published},
TypeName: "hero",
Fields: json.RawMessage(`{"Title":"Welcome","Subtitle":"Built for agents"}`),
}
_ = repo.Save(ctx, hero)
TypeName is the page type. It is a string, not a compiled Go type. "landing-page", "product-page", "team-directory" — none of these need a struct. The server renders whichever template matches the type name. Adding a new page type means adding a template file, not writing Go.
v1.31.0 ships 16 rendered block types in two families, plus a non-rendered structural root:
- Leaf blocks (9) carry content:
content_block,image,hero,quote,
contact_card, faq_item, link_item, html_block, footer.
- Collection blocks (7) hold ordered children:
content_grid,gallery,faq,
link_collection, html_grid, team, content_list.
pageis the structural root that owns sections — it has no template of its own
and never renders directly.
All of it lives in one table, smeldr_dynamic_content. Adding a new type requires no schema change.
One edge table for the whole tree
A page is not stored as a block. It is stored as a set of edges. smeldr_content_edges records that one parent contains one child at a given position, and the same table serves both "this page has these sections" and "this collection has these items".
smeldr_content_edges (parent_id, parent_type, child_id, child_type, sort_order, ...)
A whole landing page is a handful of rows. A gallery's images are more rows of the exact same shape. One query fetches the children at any level, ordered. The store exposes the obvious operations:
edges := smeldr.NewContentEdgeStore(db)
edges.AddChild(ctx, smeldr.ContentEdge{
ParentID: pageID, ParentType: "page",
ChildID: heroID, ChildType: "hero",
})
edges.Children(ctx, pageID) // ordered sections
edges.Reorder(ctx, pageID, []string{heroID, gridID}) // atomic reorder
Keeping composition in edges, never inside a block's Fields, is what makes a page reorderable and a block reusable across pages.
Rendering by convention
App.ServeBlocks walks the edge tree from a root and renders each block with its own template. The convention is one file per type at templates/blocks/<type_name>.html: hero.html, content_block.html, gallery.html. No registry, no wiring.
blocks, err := app.ServeBlocks("templates/blocks")
if err != nil {
log.Fatal(err)
}
// Assemble the page with id pageID, type "page", into HTML:
html, err := blocks.Render(ctx, "page", pageID)
Three properties matter under the hood:
- No N+1. A naive render of a hero plus three collections of six items each is
about 25 queries. ServeBlocks batch-loads each level (one query per frontier), so a page is a few queries regardless of depth. There is a counting test that fails if an N+1 sneaks back in.
- Safe by default. Only
Publishedblocks render. A missing block, a draft, a
dangling edge, a malformed field set, a missing template: each is skipped and logged, never a 500.
- PascalCase field keys. The render layer reads
Fieldskeys as template data, so
the keys are PascalCase to match: {"Title":"...","Body":"..."}, surfaced in the template as .Title and .Body. This is a hard rule. Lowercase keys store fine but render blank, which is the one trap worth remembering.
Blocks reference blocks
A content_block does not embed an image. It holds an ImageID that points at an image block. At render time ServeBlocks resolves that reference in the same batched pass and exposes the referenced block as a sub-object:
<!-- templates/blocks/content_block.html -->
<section>
<h2>{{ .Title }}</h2>
{{ with .Image }}<img src="{{ .MediaURL }}" alt="{{ .AltText }}">{{ end }}
{{ .Body }}
</section>
ImageID becomes .Image, carrying the referenced block's MediaURL, AltText, and the rest. It is one level, Published-only, batch-loaded. It is also a small preview of T06 (content relations): a reference between two items, resolved by the system rather than copied by hand.
An agent creates the page type and builds it
This is the part that changes what Smeldr is for. Twelve generic MCP tools ship in smeldr.dev/mcp v1.14.0 via mcp.WithBlocks():
- Lifecycle (Author role):
create_node,update_node,get_node,list_nodes,
publish_node, archive_node.
- Composition (Editor role):
add_section,reorder_sections,remove_section,
add_item, reorder_items, remove_item.
An agent builds a landing page entirely through the protocol — no developer involved:
1. create_node with type_name: "hero" and the hero's fields, then publish_node. 2. create_node for each feature block, publish each. 3. add_section to append each block to the page in order. 4. reorder_sections if the layout should change.
The page type — "landing-page" — does not exist anywhere in the Go code. The agent defined it at runtime by naming it and building the template. No code was written, nothing was redeployed, and every step is an ordinary, audited content operation. smeldr-cli mirrors the same surface (block node ..., block section ...) for the human fallback.
Why this is the interesting release
Most CMSs let an AI fill fields in a page a developer designed. The block system lets the agent design the page: define the type, choose the blocks, order them, reference one from another, publish the result — all as data the system understands and can render safely. That is the difference between a CMS with an AI bolted on and a content backend built for an agent to operate. The block system is the first feature where that loop runs end to end.