A Hypermedia document stores its body as a tree of small, addressable pieces called blocks: a paragraph, a heading, an image, a table row. Every block has a permanent id. A link, a comment or a citation can point at exactly one block, and two people editing different blocks never conflict. This page describes the block model as the Seed daemon and the SDK implement it today.
The block tree
The body of a document and the body of a comment are both an ordered list of block nodes. A block node is one block plus an optional list of child block nodes. Nesting is unlimited. A leaf has no children.
An editor shows the tree, but the tree does not travel on the network. A document is the resolved state of a chain of signed Changes, and each Change carries operations on blocks. ReplaceBlock sets a block's whole state. MoveBlocks places a run of blocks under a parent after a sibling. DeleteBlocks moves blocks to the trash. The daemon replays those operations with a tree CRDT to produce the tree you read. Documents explains the merge.
Two consequences follow.
A block that has state but no position is not lost. The daemon returns it in the document's detached blocks, keyed by id. The Seed app uses one detached block, named navigation, to hold a site's menu. Its children are Link blocks, and the navigation item page describes their shape.
Moving a block never rewrites its contents, and editing a block never changes its position. This keeps concurrent edits cheap to merge.
The block record
A block is a map with five reserved keys. Everything else on the block is an attribute.
key | type | meaning |
|---|---|---|
| string | the block's permanent identity inside its document; clients generate an 8-character random id |
| string | the block type, such as |
| string | the block's text, for the types that have text |
| string | the block's one link: an |
| list | inline formatting and inline links over ranges of |
| string | output only: the CID of the last Change that modified this block, filled in by the daemon when it serves a document |
Inside a signed Change, attributes sit at the top level of the block map. The Seed API and the SDK nest them under an attributes key instead, because protobuf cannot carry open fields. So an attribute may never be named id, type, text, link or annotations. The daemon also accepts a legacy encoding forever. Some early comments spelled the id key iD and put attributes in a nested attributes map. Blobs are permanent, so both spellings read back the same.
Two attributes exist on every block that can have children.
childrenType sets how the children are laid out. The children type values are Group (the default, also when the key is absent or null), Ordered, Unordered, Blockquote and Grid. So a list is a property of the parent block. The bullet belongs to the block above the items.
columnCount sets the number of columns of a Grid.
Annotations
text holds no markup. Each annotation is a layer over the block's text: a type, parallel starts and ends arrays naming one or more ranges, an optional link, and inline attributes. Offsets count Unicode code points. They do not count UTF-16 units or bytes, so a range means the same thing in every language.
type | carries | meaning |
|---|---|---|
| ranges only | text styles |
|
| a hyperlink to any URL |
|
| an inline embed or mention; see below |
| ranges only | a highlight, rendered as marked text |
|
| a style value such as a color or a font family |
A mention is an inline embed. The block's text holds a single placeholder character, U+FEFF, at the position of the mention. An Embed annotation covering that one character carries the hm:// link. mentionKind is account for a person (the link is the account or its /:profile) or document for a page. Readers render the current name of the target in place of the placeholder, so a mention follows a title change. Text fragments and search skip the placeholder characters when they count offsets.
Built-in block types
The protocol defines a strict set of fifteen types, the core block. Each type's page lists its attributes. This table gives the one-line meaning and the attributes that matter.
type | text and link | attributes | notes |
|---|---|---|---|
text, annotations |
| the default block | |
text, annotations | a section heading; its children are the section | ||
text |
| verbatim text, no annotations | |
text | LaTeX, rendered with KaTeX | ||
link ( |
| ||
link |
| an | |
link ( |
| any attachment | |
text is the label, link |
| ||
link ( |
| another document, block or discussion, shown in place | |
link ( | an external page, tweet or post | ||
link ( | a Nostr event | ||
the container of a table | |||
| a childless column marker | ||
| a row; its children are the cells | ||
| a live listing of documents |
An ipfs:// link points at a file stored as IPFS data. The Seed app recognizes three more types that are not in the core union. Slot is an invisible container with childrenType and columnCount. It puts a list or a grid at the top level of a document without a visible parent. Link is the navigation menu item described above. Group is a legacy container that some old documents still carry.
An unknown type is never an error. The wire block schema is open. A document that contains a block type this client has no schema for still parses, keeps the block's fields, and renders the block as well as it can. A third party adds a block type this way. The poll block example shows the schema side.
Embeds
An Embed block shows another Hypermedia resource inside this one. Its link is an hm:// URL, and the URL decides what is embedded: a whole document, one block with #blockId, a block and its children with #blockId+, or a text range with #blockId[start:end]. The URL also decides which version you see. With ?v= the embed is pinned to that exact version. With &l it follows the latest version. The Seed app pins the version when you embed a block from a comment, and follows the latest by default everywhere else. URLs has the grammar.
The embed view attribute chooses the rendering. Content shows the target's body. Card shows its title, summary and cover. Comments shows its discussion. Link shows a plain link. Renderers keep a stack of the resources they are inside. They stop when an embed would show a document that already contains it, so a cycle renders as a link and does not recurse.
Query blocks
A Query block is a live listing. The author stores a query, and every reader sees the current result. The query has three parts.
includes: one or more inclusions, each a space, an optional path prefix inside it, and a mode of Children or AllDescendants.
sort: a list of sort terms, each {term, reverse}. The app writes title, path, created, updated, displayTime and activity today. Older documents carry Title, UpdateTime and the other capitalized spellings, and readers normalize them. A legacy time term sorted newest first by default, so readers flip its reverse flag on the way in.
limit: an optional maximum number of results.
The block's own attributes choose the presentation. style is Card, List or Table. columnCount is the number of card columns. banner shows the first result as a banner above the rest. table config remembers which columns are visible in the table view and how wide they are.
The query object carries no filters today. Attribute filters such as "status is Done" live in the Seed API's QueryDocuments request and the Explore grammar, described in the query grammar. A query block resolves through the same daemon listing and returns each document's info and metadata. That is why a folder page is usually one query block over its own children.
Tables
A table uses three block types. There is no cell type.
Table is the container.
TableColumn blocks come first among its children. They have no children and carry only identity and order. The sibling order of the columns is the display order. width and isHeader are their attributes.
TableRow blocks follow. A row's children are its cells: ordinary Paragraph blocks whose columnId attribute names a TableColumn block id. The order of cells inside a row is ignored. A cell belongs to the column its columnId names.
Table
├── TableColumn c1
├── TableColumn c2
├── TableRow r1 (isHeader)
│ ├── Paragraph {columnId: c1} "Name"
│ └── Paragraph {columnId: c2} "Age"
└── TableRow r2
├── Paragraph {columnId: c1} "Alice"
└── Paragraph {columnId: c2} "30"A cell's identity is the pair (row block, column id). It is never a grid position. Editing a cell is a text edit on one paragraph. Adding, reordering or deleting a column is one sibling move or delete of one TableColumn block, and no cell renumbers. Two people who add a row and reorder the columns at the same time merge cleanly: the new row has a cell for every column, and the reordered columns still find their cells by id. Readers drop cells whose columnId matches no column, render missing cells empty, honor isHeader only on the first row and the first column, and drop rows or columns that appear outside a Table. The daemon enforces none of this. It treats all three as ordinary blocks, so these rules are client conventions.
Text fragments and revisions
Because blocks have ids, a URL can address text inside a document. #blockId names a block. #blockId+ names the block with its children expanded. #blockId[start:end] names a range of the block's text in Unicode code points, counted on the text with inline-embed placeholders removed. Comments, citations and embeds all use these forms. A range comment pins the document version it was made on, because a later edit would move the offsets.
The revision the daemon reports on each block is the CID of the last Change that replaced it. A citation of a block records the revision it saw, so a reader can tell whether the cited text has changed since.
Working with blocks
In the Seed app
The Seed app editor is a block editor built on BlockNote and ProseMirror. Every editor block maps one to one onto a Hypermedia block. The slash menu inserts the built-in types. The drag handle moves a block and its children together. A list, quote or grid is a setting on the parent block. Copying a block gives you its hm:// URL with the block fragment. Selecting text and choosing to comment or copy a link gives you a range fragment.
CLI
seed-cli document get <id> prints a document as markdown. Every block ends with a <!-- id:… --> comment. Block types that markdown cannot express carry type: in that comment, and attributes without a native syntax are written as attrs: JSON. document create -f page.md and document update accept the same dialect, match blocks by those ids, and emit operations only for blocks that changed. --json switches to the block tree. The reference is in the CLI guide.
SDK
The SDK exports the zod schemas for every block type (HMBlockSchema with a passthrough for unknown types), parseMarkdown and blocksToMarkdown for the lossless markdown dialect, and createDocumentBlobs to turn a block tree into signed Changes. The markdown dialect is the one the CLI prints. Its table form carries column and row identity in comments, so a table survives a round trip. See the SDK guide.
Web API
GET /api/Resource?id=hm://… returns the document with its content tree and detachedBlocks. Appending .md or .json to a site URL exports the same document with embeds, mentions and query blocks resolved. QueryBlock resolves a query block on the server. Details are in the Seed API guide.
Agents
Seed Agents and external agents read and write blocks as markdown. They do not send raw operations. The read verb returns a document as resolved markdown. The write verb accepts the dialect above and updates a document in place. An agent that edits a table must keep the <!-- col:… --> and row <!-- id:… --> comments so the table's identity survives. Claude Code with the seed-cli skill uses the CLI commands above. Building with agents has the workflow.
See also
Documents: how Changes merge into the block tree.
URLs: block and range fragments.
Comments: comments that attach to blocks.
Files: what image and file blocks link to.
Hypermedia Schemas: typed attributes and custom block types.
Schema pages: block, block/node, block/core, block/annotation, query.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime