The gap
v1.31.0 shipped the Smeldr block system: DynamicNode, a single composition edge table, 16 rendered block types, and ServeBlocks for a live blocks page at /blocks/{page}.
But there was a structural gap. Block sections could only be attached to a /blocks/ page, a standalone node type with no content lifecycle. If you wanted a devlog post with an embedded CTA section, or a solved story with a feature comparison block, blocks and content lived in separate trees with no way to connect them.
There was also a discoverability gap on the AI side. Block types had no machine-readable schema. An agent hitting create_node with type_name: "hero" had to guess the correct field names, or read source code to find them.
v1.37.0 and v1.38.0 close both gaps.
T94: Content instances as block parents
A content module (Post, Story, DocPage, any registered content type) now declares that its instances can host block sections. You do this with the BlockHost() option on NewModule:
postModule := smeldr.NewModule((*Post)(nil),
smeldr.Repo(postRepo),
smeldr.Templates("templates/devlog"),
smeldr.BlockHost(),
)
app.Content(postModule)
BlockHost() sets a flag on the module. app.Content() checks that flag and auto-registers the module with app.RegisterBlockParent. No extra wiring needed.
Once registered, you first create the block, then attach it to a post with add_section. Two steps, two MCP calls:
// Step 1: create the block (typed tool or generic create_node):
{ "tool": "create_hero", "Headline": "Welcome", "Subtext": "Built for agents" }
// → returns { "id": "01jx9ab-..." }
// Step 2: attach it to the post:
{ "tool": "add_section", "parent_id": "<post-uuid>", "child_id": "01jx9ab-..." }
The MCP server resolves the parent through the registered providers: it iterates app.BlockParents(), finds the module whose BlockParentTypeName() matches "post", and calls HasBlockParent to confirm the ID exists. If the post does not exist, the call is rejected, not silently dropped.
The ContentParentProvider interface is what ties it together:
type ContentParentProvider interface {
BlockParentTypeName() string
HasBlockParent(ctx context.Context, id string) (bool, error)
}
Module[T] implements this interface when BlockHost() is given. The type name is the lower-case Go type name (Post → "post", DocPage → "docpage"). The HasBlockParent check is a repository lookup, no separate table, no extra schema.
T97: Schema-defined block types
The second piece is a type-system layer over the 16 canonical block types. A new table, smeldr_content_type_schemas, stores one row per type. A JSON fields array describing each field's name, JSON Schema type, whether it is required, an optional format hint, and an optional description.
At startup you create the table and seed it:
func migrateDB(db *sql.DB) error {
// existing calls...
if err := smeldr.CreateBlockTables(db); err != nil {
return err
}
if err := smeldr.CreateSchemaTable(db); err != nil {
return err
}
return smeldr.SeedBlockTypeSchemas(db)
}
Both calls are idempotent: CREATE TABLE IF NOT EXISTS and INSERT OR IGNORE. Every boot is safe.
The hero schema looks like this once seeded:
{
"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"},
{"name": "AnchorID", "type": "string"}
]
}
ValidateBlockFields enforces the schema in create_node and update_node: unknown fields are rejected, required fields must be present.
What agents see in MCP
The schema layer changes how the MCP server presents block creation to agents. Before v1.38.0, there was one tool: create_node, taking a fields bag that the agent had to fill correctly. After v1.38.0, the server generates 16 typed tools at startup, one per seeded schema:
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
Each typed tool exposes the schema's fields directly as named JSON Schema parameters with types, required markers, and descriptions. An agent calling create_hero sees Headline as a required string, Subtext as an optional markdown string, and so on. No guesswork.
Two discovery tools let agents read schemas directly:
list_content_type_schemas: returns all 16 schemas with their fieldsget_content_type_schema: returns one schema bytype_name
create_node remains available and unchanged for custom or unschematised types. The typed tools are additive: they supplement, not replace.
Putting it together
With both capabilities wired, an agent can:
1. Call list_content_type_schemas to discover available block types. 2. Pick "hero" and see that Headline is required. 3. Call create_hero with the known fields, validated server-side. 4. Call add_section with parent_type: "post", parent_id: <post-id> to attach the hero to a specific devlog post.
The block system no longer lives in a separate namespace from content. Posts, stories, and doc pages can host structured, AI-composed sections. And AI agents know exactly what those sections expect.
Versions
- smeldr.dev/core v1.38.0 (T97 schema layer)
- smeldr.dev/core v1.37.0 (T94 BlockHost, included in v1.38.0 tag via combined commit)
- smeldr.dev/mcp v1.20.0 (typed create tools + schema discovery)
- smeldr.dev/mcp v1.19.0 (BlockHost parent resolution, same commit as v1.18.0)
Operators should pin v1.38.0 and v1.20.0.